Add Tailscale ZTA module (#2207)

This commit is contained in:
Hans Krutzer 2023-09-18 12:25:34 +02:00 committed by GitHub
parent e37b2ce736
commit d0201995cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 220 additions and 2 deletions

View file

@ -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} ->

View file

@ -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

View file

@ -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

View file

@ -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"},
}

View file

@ -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