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 16, 2023
1 parent b8fc4ee commit b0386f3
Show file tree
Hide file tree
Showing 3 changed files with 186 additions and 1 deletion.
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
103 changes: 103 additions & 0 deletions lib/livebook/zta/tailscale.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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 | head -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))
user = GenServer.call(name, {:authenticate, remote_ip})
{conn, user}
end

@impl true
def init(options) do
state = struct!(__MODULE__, options)
{:ok, state}
end

@impl true
def handle_call({:authenticate, remote_ip}, _from, state) do
user = authenticated_ip(remote_ip, state)
{:reply, user, state}
end

defp authenticated_ip(remote_ip, %{address: address} = _state) do
{url, options} =
if String.starts_with?(address, "/") do
# Assume address starting with a slash is a Unix socket
{
"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"]
]
}
else
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}
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
81 changes: 81 additions & 0 deletions test/livebook/zta/tailscale_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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"

setup do
bypass = Bypass.open()

Bypass.expect(bypass, fn conn ->
assert %{"addr" => "151.236.219.228:1"} = conn.query_params

conn
|> put_resp_content_type("application/json")
|> send_resp(
200,
Jason.encode!(%{
UserProfile: %{
ID: 1_234_567_890,
DisplayName: "John",
LoginName: "[email protected]"
}
})
)
end)

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", %{options: options, conn: conn} do
start_supervised!({Tailscale, options})
{_conn, user} = Tailscale.authenticate(@name, conn, @fields)
assert %{id: "1234567890", email: "[email protected]", name: "John"} = user
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 b0386f3

Please sign in to comment.