diff --git a/cmd/nginx-auth/.gitignore b/cmd/nginx-auth/.gitignore new file mode 100644 index 000000000..3c608aeb1 --- /dev/null +++ b/cmd/nginx-auth/.gitignore @@ -0,0 +1,4 @@ +nga.sock +*.deb +*.rpm +tailscale.nginx-auth diff --git a/cmd/nginx-auth/README.md b/cmd/nginx-auth/README.md new file mode 100644 index 000000000..ec6d6527f --- /dev/null +++ b/cmd/nginx-auth/README.md @@ -0,0 +1,135 @@ +# nginx-auth + +This is a tool that allows users to use Tailscale Whois authentication with +NGINX as a reverse proxy. This allows users that already have a bunch of +services hosted on an internal NGINX server to point those domains to the +Tailscale IP of the NGINX server and then seamlessly use Tailscale for +authentication. + +Many thanks to [@zrail](https://twitter.com/zrail/status/1511788463586222087) on +Twitter for introducing the basic idea and offering some sample code. This +program is based on that sample code with security enhancements. Namely: + +* This listens over a UNIX socket instead of a TCP socket, to prevent + leakage to the network +* This uses systemd socket activation so that systemd owns the socket + and can then lock down the service to the bare minimum required to do + its job without having to worry about dropping permissions +* This provides additional information in HTTP response headers that can + be useful for integrating with various services + +## Configuration + +In order to protect a service with this tool, do the following in the respective +`server` block: + +Create an authentication location with the `internal` flag set: + +```nginx +location /auth { + internal; + + proxy_pass http://unix:/run/tailscale.nginx-auth.sock; + proxy_pass_request_body off; + + proxy_set_header Host $http_host; + proxy_set_header Remote-Addr $remote_addr; + proxy_set_header Remote-Port $remote_port; + proxy_set_header Original-URI $request_uri; +} +``` + +Then add the following to the `location /` block: + +``` +auth_request /auth; +auth_request_set $auth_user $upstream_http_tailscale_user; +auth_request_set $auth_name $upstream_http_tailscale_name; +auth_request_set $auth_login $upstream_http_tailscale_login; +auth_request_set $auth_tailnet $upstream_http_tailscale_tailnet; +auth_request_set $auth_profile_picture $upstream_http_tailscale_profile_picture; + +proxy_set_header X-Webauth-User "$auth_user"; +proxy_set_header X-Webauth-Name "$auth_name"; +proxy_set_header X-Webauth-Login "$auth_login"; +proxy_set_header X-Webauth-Tailnet "$auth_tailnet"; +proxy_set_header X-Webauth-Profile-Picture "$auth_profile_picture"; +``` + +When this configuration is used with a Go HTTP handler such as this: + +```go +http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { + e := json.NewEncoder(w) + e.SetIndent("", " ") + e.Encode(r.Header) +}) +``` + +You will get output like this: + +```json +{ + "Accept": [ + "*/*" + ], + "Connection": [ + "upgrade" + ], + "User-Agent": [ + "curl/7.82.0" + ], + "X-Webauth-Login": [ + "Xe" + ], + "X-Webauth-Name": [ + "Xe Iaso" + ], + "X-Webauth-Profile-Picture": [ + "https://avatars.githubusercontent.com/u/529003?v=4" + ], + "X-Webauth-Tailnet": [ + "cetacean.org.github" + ] + "X-Webauth-User": [ + "Xe@github" + ] +} +``` + +## Headers + +The authentication service provides the following headers to decorate your +proxied requests: + +| Header | Example Value | Description | +| :------ | :-------------- | :---------- | +| `Tailscale-User` | `azurediamond@hunter2.net` | The Tailscale username the remote machine is logged in as in user@host form | +| `Tailscale-Login` | `azurediamond` | The user portion of the Tailscale username the remote machine is logged in as | +| `Tailscale-Name` | `Azure Diamond` | The "real name" of the Tailscale user the machine is logged in as | +| `Tailscale-Profile-Picture` | `https://i.kym-cdn.com/photos/images/newsfeed/001/065/963/ae0.png` | The profile picture provided by the Identity Provider your tailnet uses | +| `Tailscale-Tailnet` | `hunter2.net` | The tailnet name | + +Most of the time you can set `X-Webauth-User` to the contents of the +`Tailscale-User` header, but some services may not accept a username with an `@` +symbol in it. If this is the case, set `X-Webauth-User` to the `Tailscale-Login` +header. + +The `Tailscale-Tailnet` header can help you identify which tailnet the session +is coming from. If you are using node sharing, this can help you make sure that +you aren't giving administrative access to people outside your tailnet. You will +need to be sure to check this in your application code. If you use OpenResty, +you may be able to do more complicated access controls than you can with NGINX +alone. + +## Building + +Install `cmd/mkpkg`: + +``` +cd .. && go install ./mkpkg +``` + +Then run `./mkdeb.sh`. It will emit a `.deb` and `.rpm` package for amd64 +machines (Linux uname flag: `x86_64`). You can add these to your deployment +methods as you see fit. diff --git a/cmd/nginx-auth/mkdeb.sh b/cmd/nginx-auth/mkdeb.sh new file mode 100755 index 000000000..2bdf41c48 --- /dev/null +++ b/cmd/nginx-auth/mkdeb.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -e + +CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build -o tailscale.nginx-auth . + +mkpkg \ + --out tailscale-nginx-auth-0.1.0-amd64.deb \ + --name=tailscale-nginx-auth \ + --version=0.1.0 \ + --type=deb\ + --arch=amd64 \ + --description="Tailscale NGINX authentication protocol handler" \ + --files=./tailscale.nginx-auth:/usr/sbin/tailscale.nginx-auth,./tailscale.nginx-auth.socket:/lib/systemd/system/tailscale.nginx-auth.socket,./tailscale.nginx-auth.service:/lib/systemd/system/tailscale.nginx-auth.service + +mkpkg \ + --out tailscale-nginx-auth-0.1.0-amd64.rpm \ + --name=tailscale-nginx-auth \ + --version=0.1.0 \ + --type=rpm \ + --arch=amd64 \ + --description="Tailscale NGINX authentication protocol handler" \ + --files=./tailscale.nginx-auth:/usr/sbin/tailscale.nginx-auth,./tailscale.nginx-auth.socket:/lib/systemd/system/tailscale.nginx-auth.socket,./tailscale.nginx-auth.service:/lib/systemd/system/tailscale.nginx-auth.service diff --git a/cmd/nginx-auth/nginx-auth.go b/cmd/nginx-auth/nginx-auth.go new file mode 100644 index 000000000..a64df80cc --- /dev/null +++ b/cmd/nginx-auth/nginx-auth.go @@ -0,0 +1,120 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux + +// Command nginx-auth is a tool that allows users to use Tailscale Whois +// authentication with NGINX as a reverse proxy. This allows users that +// already have a bunch of services hosted on an internal NGINX server +// to point those domains to the Tailscale IP of the NGINX server and +// then seamlessly use Tailscale for authentication. +package main + +import ( + "flag" + "log" + "net" + "net/http" + "net/netip" + "os" + "strings" + + "github.com/coreos/go-systemd/activation" + "tailscale.com/client/tailscale" +) + +var ( + sockPath = flag.String("sockpath", "", "the filesystem path for the unix socket this service exposes") +) + +func main() { + flag.Parse() + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + remoteHost := r.Header.Get("Remote-Addr") + remotePort := r.Header.Get("Remote-Port") + if remoteHost == "" || remotePort == "" { + w.WriteHeader(http.StatusBadRequest) + log.Println("set Remote-Addr to $remote_addr and Remote-Port to $remote_port in your nginx config") + return + } + + remoteAddrStr := net.JoinHostPort(remoteHost, remotePort) + remoteAddr, err := netip.ParseAddrPort(remoteAddrStr) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + log.Printf("remote address and port are not valid: %v", err) + return + } + + info, err := tailscale.WhoIs(r.Context(), remoteAddr.String()) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + log.Printf("can't look up %s: %v", remoteAddr, err) + return + } + + if len(info.Node.Tags) != 0 { + w.WriteHeader(http.StatusForbidden) + log.Printf("node %s is tagged", info.Node.Hostinfo.Hostname()) + return + } + + _, tailnet, ok := strings.Cut(info.Node.Name, info.Node.ComputedName+".") + if !ok { + w.WriteHeader(http.StatusUnauthorized) + log.Printf("can't extract tailnet name from hostname %q", info.Node.Name) + return + } + tailnet, _, ok = strings.Cut(tailnet, ".beta.tailscale.net") + if !ok { + w.WriteHeader(http.StatusUnauthorized) + log.Printf("can't extract tailnet name from hostname %q", info.Node.Name) + return + } + + h := w.Header() + h.Set("Tailscale-Login", strings.Split(info.UserProfile.LoginName, "@")[0]) + h.Set("Tailscale-User", info.UserProfile.LoginName) + h.Set("Tailscale-Name", info.UserProfile.DisplayName) + h.Set("Tailscale-Profile-Picture", info.UserProfile.ProfilePicURL) + h.Set("Tailscale-Tailnet", tailnet) + w.WriteHeader(http.StatusNoContent) + }) + + if *sockPath != "" { + _ = os.Remove(*sockPath) // ignore error, this file may not already exist + ln, err := net.Listen("unix", *sockPath) + if err != nil { + log.Fatalf("can't listen on %s: %v", *sockPath, err) + } + defer ln.Close() + + log.Printf("listening on %s", *sockPath) + log.Fatal(http.Serve(ln, mux)) + } + + listeners, err := activation.Listeners() + if err != nil { + log.Fatalf("no sockets passed to this service with systemd: %v", err) + } + + // NOTE(Xe): normally you'd want to make a waitgroup here and then register + // each listener with it. In this case I want this to blow up horribly if + // any of the listeners stop working. systemd will restart it due to the + // socket activation at play. + // + // TL;DR: Let it crash, it will come back + for _, ln := range listeners { + go func(ln net.Listener) { + log.Printf("listening on %s", ln.Addr()) + log.Fatal(http.Serve(ln, mux)) + }(ln) + } + + for { + select {} + } +} diff --git a/cmd/nginx-auth/tailscale.nginx-auth.service b/cmd/nginx-auth/tailscale.nginx-auth.service new file mode 100644 index 000000000..086f6c774 --- /dev/null +++ b/cmd/nginx-auth/tailscale.nginx-auth.service @@ -0,0 +1,11 @@ +[Unit] +Description=Tailscale NGINX Authentication service +After=nginx.service +Wants=nginx.service + +[Service] +ExecStart=/usr/sbin/tailscale.nginx-auth +DynamicUser=yes + +[Install] +WantedBy=default.target diff --git a/cmd/nginx-auth/tailscale.nginx-auth.socket b/cmd/nginx-auth/tailscale.nginx-auth.socket new file mode 100644 index 000000000..7e5641ff3 --- /dev/null +++ b/cmd/nginx-auth/tailscale.nginx-auth.socket @@ -0,0 +1,9 @@ +[Unit] +Description=Tailscale NGINX Authentication socket +PartOf=tailscale.nginx-auth.service + +[Socket] +ListenStream=/var/run/tailscale.nginx-auth.sock + +[Install] +WantedBy=sockets.target \ No newline at end of file diff --git a/go.mod b/go.mod index 82f90f723..a3f6d4cdb 100644 --- a/go.mod +++ b/go.mod @@ -105,6 +105,7 @@ require ( github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/charithe/durationcheck v0.0.9 // indirect github.com/chavacava/garif v0.0.0-20210405164556-e8a0a408d6af // indirect + github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect github.com/daixiang0/gci v0.2.9 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/denis-tingajkin/go-header v0.4.2 // indirect diff --git a/go.sum b/go.sum index eceab1a29..4230c014c 100644 --- a/go.sum +++ b/go.sum @@ -223,6 +223,8 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190620071333-e64a0ec8b42a/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=