Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Tailscale ZTA module #2207

Merged
merged 2 commits into from
Sep 18, 2023
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
3 changes: 2 additions & 1 deletion lib/livebook/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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} ->
Expand Down
107 changes: 107 additions & 0 deletions lib/livebook/zta/tailscale.ex
Original file line number Diff line number Diff line change
@@ -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
hkrutzer marked this conversation as resolved.
Show resolved Hide resolved

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://:[email protected]:$port livebook server
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LIVEBOOK_IP=$(exec ...)

in my local testing (against Mac App Store-installed tailscale) it was enough to set LIVEBOOK_IP to $(tailscale ip --1) so maybe we show that here too?

As an aside, @josevalim: would it be possible to just set LIVEBOOK_IDENTITY_PROVIDER=tailscale and do the rest in Elixir e.g. when the app boots? I think it would be a much better UI if users don't have to copy paste a bunch of shell commands.

Similarly, perhaps we don't need to require users to set LIVEBOOK_IP but can do that ourselves internally.

That being said I think it is totally fine to start with something really simple if verbose and improve this as more people use the feature.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's update the examples to use tailscale ip --1 but require those env vars to be set. I don't want to rely on lsof for macos and the setting of LIVEBOOK_IP happens at a very different place which I am not sure it is worth coupling.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in my local testing (against Mac App Store-installed tailscale) it was enough to set LIVEBOOK_IP to $(tailscale ip --1) so maybe we show that here too?

It works if you have a Tailscale alias in your shell, I got this command here because it (should) work on all types of MacOS installs without requiring an alias to be set.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good point, the CLI doesn’t get installed by default. Fine by me.

Copy link
Contributor

@wojtekmach wojtekmach Sep 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or in our instructions we do the alias. I think that’s better. And if we can somehow get the port and password out of the cli with less code that’s even better. But no strong opinion.

```
"""

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the socket path is incorrect (see my comment above), the error is pretty bad:

[error] retry: got exception, will retry in 1000ms, 3 attempts left
[error] ** (Mint.TransportError) no such file or directory
[error] retry: got exception, will retry in 2000ms, 2 attempts left
[error] ** (Mint.TransportError) no such file or directory
[error] retry: got exception, will retry in 4000ms, 1 attempt left
[error] ** (Mint.TransportError) no such file or directory
** (Mint.TransportError) no such file or directory
    (req 0.3.8) lib/req.ex:372: Req.get!/2
    iex:3: (file)

my suggestion is to handle this particular scenario early on, perhaps something like this:

Suggested change
# Assume address not starting with http is a Unix socket
unless File.exists?(address) do
raise "socket doesn't exist: #{inspect(address)}"
end

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added!

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
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
Expand Down Expand Up @@ -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"},
}
107 changes: 107 additions & 0 deletions test/livebook/zta/tailscale_test.exs
Original file line number Diff line number Diff line change
@@ -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: "[email protected]"
}
})
)
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: "[email protected]", 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: "[email protected]", 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