-
Notifications
You must be signed in to change notification settings - Fork 441
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
220 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |