mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-02-23 22:37:41 +08:00
Add Tailscale ZTA module (#2207)
This commit is contained in:
parent
e37b2ce736
commit
d0201995cb
5 changed files with 220 additions and 2 deletions
|
@ -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} ->
|
||||
|
|
107
lib/livebook/zta/tailscale.ex
Normal file
107
lib/livebook/zta/tailscale.ex
Normal 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
|
3
mix.exs
3
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
|
||||
|
||||
|
|
2
mix.lock
2
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"},
|
||||
}
|
||||
|
|
107
test/livebook/zta/tailscale_test.exs
Normal file
107
test/livebook/zta/tailscale_test.exs
Normal 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
|
Loading…
Reference in a new issue