1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
module ProTacts
  # Gates every request on the identity headers Tailscale serve injects.
  #
  # Serve sets Tailscale-User-Login from the tailnet identity of the calling
  # node, and strips the header from incoming requests before proxying so a
  # client cannot supply its own. That makes the header trustworthy, but only
  # behind serve: reached directly, this middleware trusts whatever it is
  # handed. The app must not be listening anywhere but localhost.
  #
  # Failing closed covers the two cases Tailscale documents as having no
  # identity: Funnel traffic, which is public, and traffic from tagged
  # devices. A family device that gets tagged will start seeing 403s.
  #
  # Any tailnet identity is accepted. Getting onto the tailnet is the access
  # control; the address book has no per-user view to protect.
  class TailscaleAuth
    # @rbs @app: Rack::_App

    LOGIN_HEADER = "HTTP_TAILSCALE_USER_LOGIN"
    NAME_HEADER = "HTTP_TAILSCALE_USER_NAME"

    # Where the authenticated login lands for anything downstream that wants
    # to know who is asking.
    IDENTITY = "pro_tacts.user"

    #: (Rack::_App app) -> void
    def initialize(app)
      @app = app
    end

    #: (Rack::env env) -> Rack::response
    def call(env)
      login = env[LOGIN_HEADER].to_s.strip

      # 403 rather than 401: no credentials the client could supply would
      # help, so there is no challenge worth sending.
      return forbidden if login.empty?

      env[IDENTITY] = login
      @app.call(env)
    end

    private

    #: () -> Rack::response
    def forbidden
      body = "Forbidden: no Tailscale identity on this request.\n"
      [403, { "Content-Type" => "text/plain", "Content-Length" => body.bytesize.to_s }, [body]]
    end
  end
end