Skip to content

Commit

Permalink
Add Tailscale ZTA module
Browse files Browse the repository at this point in the history
  • Loading branch information
hkrutzer committed Sep 18, 2023
1 parent e37b2ce commit d18f4f7
Show file tree
Hide file tree
Showing 5 changed files with 220 additions and 2 deletions.
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
@doc """
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://:[email protected]:$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
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

0 comments on commit d18f4f7

Please sign in to comment.