ipn/ipnlocal/serve: add grant headers
Updates tailscale/corp/#28372 Signed-off-by: Gesa Stupperich <gesa@tailscale.com>
This commit is contained in:
committed by
Gesa Stupperich
parent
f4e2720821
commit
576aacd459
@@ -162,20 +162,20 @@ type serveEnv struct {
|
|||||||
json bool // output JSON (status only for now)
|
json bool // output JSON (status only for now)
|
||||||
|
|
||||||
// v2 specific flags
|
// v2 specific flags
|
||||||
bg bgBoolFlag // background mode
|
bg bgBoolFlag // background mode
|
||||||
setPath string // serve path
|
setPath string // serve path
|
||||||
https uint // HTTP port
|
https uint // HTTP port
|
||||||
http uint // HTTP port
|
http uint // HTTP port
|
||||||
tcp uint // TCP port
|
tcp uint // TCP port
|
||||||
tlsTerminatedTCP uint // a TLS terminated TCP port
|
tlsTerminatedTCP uint // a TLS terminated TCP port
|
||||||
subcmd serveMode // subcommand
|
subcmd serveMode // subcommand
|
||||||
yes bool // update without prompt
|
yes bool // update without prompt
|
||||||
service tailcfg.ServiceName // service name
|
service tailcfg.ServiceName // service name
|
||||||
tun bool // redirect traffic to OS for service
|
tun bool // redirect traffic to OS for service
|
||||||
allServices bool // apply config file to all services
|
allServices bool // apply config file to all services
|
||||||
|
userCaps []tailcfg.PeerCapability // user capabilities to forward
|
||||||
|
|
||||||
lc localServeClient // localClient interface, specific to serve
|
lc localServeClient // localClient interface, specific to serve
|
||||||
|
|
||||||
// optional stuff for tests:
|
// optional stuff for tests:
|
||||||
testFlagOut io.Writer
|
testFlagOut io.Writer
|
||||||
testStdout io.Writer
|
testStdout io.Writer
|
||||||
|
|||||||
@@ -96,6 +96,28 @@ func (b *bgBoolFlag) String() string {
|
|||||||
return strconv.FormatBool(b.Value)
|
return strconv.FormatBool(b.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type userCapsFlag struct {
|
||||||
|
Value *[]tailcfg.PeerCapability
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set appends s to the list of userCaps.
|
||||||
|
func (u *userCapsFlag) Set(s string) error {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*u.Value = append(*u.Value, tailcfg.PeerCapability(s))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the string representation of the userCaps slice.
|
||||||
|
func (u *userCapsFlag) String() string {
|
||||||
|
s := make([]string, len(*u.Value))
|
||||||
|
for i, v := range *u.Value {
|
||||||
|
s[i] = string(v)
|
||||||
|
}
|
||||||
|
return strings.Join(s, ",")
|
||||||
|
}
|
||||||
|
|
||||||
var serveHelpCommon = strings.TrimSpace(`
|
var serveHelpCommon = strings.TrimSpace(`
|
||||||
<target> can be a file, directory, text, or most commonly the location to a service running on the
|
<target> can be a file, directory, text, or most commonly the location to a service running on the
|
||||||
local machine. The location to the location service can be expressed as a port number (e.g., 3000),
|
local machine. The location to the location service can be expressed as a port number (e.g., 3000),
|
||||||
@@ -199,6 +221,7 @@ func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command {
|
|||||||
fs.UintVar(&e.https, "https", 0, "Expose an HTTPS server at the specified port (default mode)")
|
fs.UintVar(&e.https, "https", 0, "Expose an HTTPS server at the specified port (default mode)")
|
||||||
if subcmd == serve {
|
if subcmd == serve {
|
||||||
fs.UintVar(&e.http, "http", 0, "Expose an HTTP server at the specified port")
|
fs.UintVar(&e.http, "http", 0, "Expose an HTTP server at the specified port")
|
||||||
|
fs.Var(&userCapsFlag{Value: &e.userCaps}, "usercaps", "User capability to forward to the server (can be specified multiple times)")
|
||||||
}
|
}
|
||||||
fs.UintVar(&e.tcp, "tcp", 0, "Expose a TCP forwarder to forward raw TCP packets at the specified port")
|
fs.UintVar(&e.tcp, "tcp", 0, "Expose a TCP forwarder to forward raw TCP packets at the specified port")
|
||||||
fs.UintVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", 0, "Expose a TCP forwarder to forward TLS-terminated TCP packets at the specified port")
|
fs.UintVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", 0, "Expose a TCP forwarder to forward TLS-terminated TCP packets at the specified port")
|
||||||
@@ -469,7 +492,7 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
|
|||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
target = args[0]
|
target = args[0]
|
||||||
}
|
}
|
||||||
err = e.setServe(sc, dnsName, srvType, srvPort, mount, target, funnel, magicDNSSuffix)
|
err = e.setServe(sc, dnsName, srvType, srvPort, mount, target, funnel, magicDNSSuffix, e.userCaps)
|
||||||
msg = e.messageForPort(sc, st, dnsName, srvType, srvPort)
|
msg = e.messageForPort(sc, st, dnsName, srvType, srvPort)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -790,7 +813,7 @@ func (e *serveEnv) runServeSetConfig(ctx context.Context, args []string) (err er
|
|||||||
for name, details := range scf.Services {
|
for name, details := range scf.Services {
|
||||||
for ppr, ep := range details.Endpoints {
|
for ppr, ep := range details.Endpoints {
|
||||||
if ep.Protocol == conffile.ProtoTUN {
|
if ep.Protocol == conffile.ProtoTUN {
|
||||||
err := e.setServe(sc, name.String(), serveTypeTUN, 0, "", "", false, magicDNSSuffix)
|
err := e.setServe(sc, name.String(), serveTypeTUN, 0, "", "", false, magicDNSSuffix, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -812,7 +835,7 @@ func (e *serveEnv) runServeSetConfig(ctx context.Context, args []string) (err er
|
|||||||
portStr := fmt.Sprint(destPort)
|
portStr := fmt.Sprint(destPort)
|
||||||
target = fmt.Sprintf("%s://%s", ep.Protocol, net.JoinHostPort(ep.Destination, portStr))
|
target = fmt.Sprintf("%s://%s", ep.Protocol, net.JoinHostPort(ep.Destination, portStr))
|
||||||
}
|
}
|
||||||
err := e.setServe(sc, name.String(), serveType, port, "/", target, false, magicDNSSuffix)
|
err := e.setServe(sc, name.String(), serveType, port, "/", target, false, magicDNSSuffix, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("service %q: %w", name, err)
|
return fmt.Errorf("service %q: %w", name, err)
|
||||||
}
|
}
|
||||||
@@ -915,12 +938,12 @@ func serveFromPortHandler(tcp *ipn.TCPPortHandler) serveType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *serveEnv) setServe(sc *ipn.ServeConfig, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool, mds string) error {
|
func (e *serveEnv) setServe(sc *ipn.ServeConfig, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool, mds string, caps []tailcfg.PeerCapability) error {
|
||||||
// update serve config based on the type
|
// update serve config based on the type
|
||||||
switch srvType {
|
switch srvType {
|
||||||
case serveTypeHTTPS, serveTypeHTTP:
|
case serveTypeHTTPS, serveTypeHTTP:
|
||||||
useTLS := srvType == serveTypeHTTPS
|
useTLS := srvType == serveTypeHTTPS
|
||||||
err := e.applyWebServe(sc, dnsName, srvPort, useTLS, mount, target, mds)
|
err := e.applyWebServe(sc, dnsName, srvPort, useTLS, mount, target, mds, caps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed apply web serve: %w", err)
|
return fmt.Errorf("failed apply web serve: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1084,7 +1107,7 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN
|
|||||||
return output.String()
|
return output.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, useTLS bool, mount, target string, mds string) error {
|
func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, useTLS bool, mount, target, mds string, caps []tailcfg.PeerCapability) error {
|
||||||
h := new(ipn.HTTPHandler)
|
h := new(ipn.HTTPHandler)
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(target, "text:"):
|
case strings.HasPrefix(target, "text:"):
|
||||||
@@ -1118,6 +1141,7 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
h.Proxy = t
|
h.Proxy = t
|
||||||
|
h.UserCaps = caps
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: validation needs to check nested foreground configs
|
// TODO: validation needs to check nested foreground configs
|
||||||
|
|||||||
@@ -857,6 +857,53 @@ func TestServeDevConfigMutations(t *testing.T) {
|
|||||||
wantErr: anyErr(),
|
wantErr: anyErr(),
|
||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "forward_grant_header",
|
||||||
|
steps: []step{
|
||||||
|
{
|
||||||
|
command: cmd("serve --bg --usercaps=example.com/cap/foo 3000"),
|
||||||
|
want: &ipn.ServeConfig{
|
||||||
|
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||||
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||||
|
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||||
|
"/": {
|
||||||
|
Proxy: "http://127.0.0.1:3000",
|
||||||
|
UserCaps: []tailcfg.PeerCapability{"example.com/cap/foo"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: cmd("serve --bg --usercaps=example.com/cap/foo --usercaps=example.com/cap/bar 3000"),
|
||||||
|
want: &ipn.ServeConfig{
|
||||||
|
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||||
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||||
|
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||||
|
"/": {
|
||||||
|
Proxy: "http://127.0.0.1:3000",
|
||||||
|
UserCaps: []tailcfg.PeerCapability{"example.com/cap/foo", "example.com/cap/bar"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: cmd("serve --bg --usercaps=example.com/cap/bar 3000"),
|
||||||
|
want: &ipn.ServeConfig{
|
||||||
|
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||||
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||||
|
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||||
|
"/": {
|
||||||
|
Proxy: "http://127.0.0.1:3000",
|
||||||
|
UserCaps: []tailcfg.PeerCapability{"example.com/cap/bar"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
@@ -2009,7 +2056,7 @@ func TestSetServe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
err := e.setServe(tt.cfg, tt.dnsName, tt.srvType, tt.srvPort, tt.mountPath, tt.target, tt.allowFunnel, magicDNSSuffix)
|
err := e.setServe(tt.cfg, tt.dnsName, tt.srvType, tt.srvPort, tt.mountPath, tt.target, tt.allowFunnel, magicDNSSuffix, nil)
|
||||||
if err != nil && !tt.expectErr {
|
if err != nil && !tt.expectErr {
|
||||||
t.Fatalf("got error: %v; did not expect error.", err)
|
t.Fatalf("got error: %v; did not expect error.", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-4
@@ -232,14 +232,16 @@ func (src *HTTPHandler) Clone() *HTTPHandler {
|
|||||||
}
|
}
|
||||||
dst := new(HTTPHandler)
|
dst := new(HTTPHandler)
|
||||||
*dst = *src
|
*dst = *src
|
||||||
|
dst.UserCaps = append(src.UserCaps[:0:0], src.UserCaps...)
|
||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||||
var _HTTPHandlerCloneNeedsRegeneration = HTTPHandler(struct {
|
var _HTTPHandlerCloneNeedsRegeneration = HTTPHandler(struct {
|
||||||
Path string
|
Path string
|
||||||
Proxy string
|
Proxy string
|
||||||
Text string
|
Text string
|
||||||
|
UserCaps []tailcfg.PeerCapability
|
||||||
}{})
|
}{})
|
||||||
|
|
||||||
// Clone makes a deep copy of WebServerConfig.
|
// Clone makes a deep copy of WebServerConfig.
|
||||||
@@ -256,7 +258,7 @@ func (src *WebServerConfig) Clone() *WebServerConfig {
|
|||||||
if v == nil {
|
if v == nil {
|
||||||
dst.Handlers[k] = nil
|
dst.Handlers[k] = nil
|
||||||
} else {
|
} else {
|
||||||
dst.Handlers[k] = ptr.To(*v)
|
dst.Handlers[k] = v.Clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-3
@@ -891,11 +891,17 @@ func (v HTTPHandlerView) Proxy() string { return v.ж.Proxy }
|
|||||||
// plaintext to serve (primarily for testing)
|
// plaintext to serve (primarily for testing)
|
||||||
func (v HTTPHandlerView) Text() string { return v.ж.Text }
|
func (v HTTPHandlerView) Text() string { return v.ж.Text }
|
||||||
|
|
||||||
|
// peer capabilities to forward in grant header, e.g. example.com/cap/mon
|
||||||
|
func (v HTTPHandlerView) UserCaps() views.Slice[tailcfg.PeerCapability] {
|
||||||
|
return views.SliceOf(v.ж.UserCaps)
|
||||||
|
}
|
||||||
|
|
||||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||||
var _HTTPHandlerViewNeedsRegeneration = HTTPHandler(struct {
|
var _HTTPHandlerViewNeedsRegeneration = HTTPHandler(struct {
|
||||||
Path string
|
Path string
|
||||||
Proxy string
|
Proxy string
|
||||||
Text string
|
Text string
|
||||||
|
UserCaps []tailcfg.PeerCapability
|
||||||
}{})
|
}{})
|
||||||
|
|
||||||
// View returns a read-only view of WebServerConfig.
|
// View returns a read-only view of WebServerConfig.
|
||||||
|
|||||||
+67
-1
@@ -40,6 +40,7 @@ import (
|
|||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/types/lazy"
|
"tailscale.com/types/lazy"
|
||||||
"tailscale.com/types/logger"
|
"tailscale.com/types/logger"
|
||||||
|
"tailscale.com/types/views"
|
||||||
"tailscale.com/util/backoff"
|
"tailscale.com/util/backoff"
|
||||||
"tailscale.com/util/clientmetric"
|
"tailscale.com/util/clientmetric"
|
||||||
"tailscale.com/util/ctxkey"
|
"tailscale.com/util/ctxkey"
|
||||||
@@ -64,6 +65,7 @@ func init() {
|
|||||||
const (
|
const (
|
||||||
contentTypeHeader = "Content-Type"
|
contentTypeHeader = "Content-Type"
|
||||||
grpcBaseContentType = "application/grpc"
|
grpcBaseContentType = "application/grpc"
|
||||||
|
grantHeaderMaxSize = 15360 // 15 KiB
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrETagMismatch signals that the given
|
// ErrETagMismatch signals that the given
|
||||||
@@ -79,7 +81,8 @@ type serveHTTPContext struct {
|
|||||||
DestPort uint16
|
DestPort uint16
|
||||||
|
|
||||||
// provides funnel-specific context, nil if not funneled
|
// provides funnel-specific context, nil if not funneled
|
||||||
Funnel *funnelFlow
|
Funnel *funnelFlow
|
||||||
|
PeerCapsFilter views.Slice[tailcfg.PeerCapability]
|
||||||
}
|
}
|
||||||
|
|
||||||
// funnelFlow represents a funneled connection initiated via IngressPeer
|
// funnelFlow represents a funneled connection initiated via IngressPeer
|
||||||
@@ -803,6 +806,7 @@ func (rp *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
r.Out.Host = r.In.Host
|
r.Out.Host = r.In.Host
|
||||||
addProxyForwardedHeaders(r)
|
addProxyForwardedHeaders(r)
|
||||||
rp.lb.addTailscaleIdentityHeaders(r)
|
rp.lb.addTailscaleIdentityHeaders(r)
|
||||||
|
rp.lb.addTailscaleGrantHeader(r)
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// There is no way to autodetect h2c as per RFC 9113
|
// There is no way to autodetect h2c as per RFC 9113
|
||||||
@@ -927,6 +931,62 @@ func encTailscaleHeaderValue(v string) string {
|
|||||||
return mime.QEncoding.Encode("utf-8", v)
|
return mime.QEncoding.Encode("utf-8", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *LocalBackend) addTailscaleGrantHeader(r *httputil.ProxyRequest) {
|
||||||
|
r.Out.Header.Del("Tailscale-User-Capabilities")
|
||||||
|
|
||||||
|
c, ok := serveHTTPContextKey.ValueOk(r.Out.Context())
|
||||||
|
if !ok || c.Funnel != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter := c.PeerCapsFilter
|
||||||
|
if filter.IsNil() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peerCaps := b.PeerCaps(c.SrcAddr.Addr())
|
||||||
|
if peerCaps == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
peerCapsFiltered := make(map[tailcfg.PeerCapability][]tailcfg.RawMessage, filter.Len())
|
||||||
|
for _, cap := range filter.AsSlice() {
|
||||||
|
if peerCaps.HasCapability(cap) {
|
||||||
|
peerCapsFiltered[cap] = peerCaps[cap]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serialized, truncated, err := serializeUpToNBytes(peerCapsFiltered, grantHeaderMaxSize)
|
||||||
|
if err != nil {
|
||||||
|
b.logf("serve: failed to serialize PeerCapMap: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if truncated {
|
||||||
|
b.logf("serve: serialized PeerCapMap exceeds %d bytes, forwarding truncated PeerCapMap", grantHeaderMaxSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Out.Header.Set("Tailscale-User-Capabilities", encTailscaleHeaderValue(serialized))
|
||||||
|
}
|
||||||
|
|
||||||
|
// serializeUpToNBytes serializes capMap. It arbitrarily truncates entries from the capMap
|
||||||
|
// if the size of the serialized capMap would exceed N bytes.
|
||||||
|
func serializeUpToNBytes(capMap tailcfg.PeerCapMap, N int) (string, bool, error) {
|
||||||
|
numBytes := 0
|
||||||
|
capped := false
|
||||||
|
result := tailcfg.PeerCapMap{}
|
||||||
|
for k, v := range capMap {
|
||||||
|
numBytes += len(k) + len(v)
|
||||||
|
if numBytes > N {
|
||||||
|
capped = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
marshalled, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return string(marshalled), capped, nil
|
||||||
|
}
|
||||||
|
|
||||||
// serveWebHandler is an http.HandlerFunc that maps incoming requests to the
|
// serveWebHandler is an http.HandlerFunc that maps incoming requests to the
|
||||||
// correct *http.
|
// correct *http.
|
||||||
func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) {
|
func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -950,6 +1010,12 @@ func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "unknown proxy destination", http.StatusInternalServerError)
|
http.Error(w, "unknown proxy destination", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Inject user capabilities to forward into the request context
|
||||||
|
c, ok := serveHTTPContextKey.ValueOk(r.Context())
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.PeerCapsFilter = h.UserCaps()
|
||||||
h := p.(http.Handler)
|
h := p.(http.Handler)
|
||||||
// Trim the mount point from the URL path before proxying. (#6571)
|
// Trim the mount point from the URL path before proxying. (#6571)
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -27,6 +28,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"tailscale.com/control/controlclient"
|
||||||
"tailscale.com/health"
|
"tailscale.com/health"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/store/mem"
|
"tailscale.com/ipn/store/mem"
|
||||||
@@ -41,6 +43,7 @@ import (
|
|||||||
"tailscale.com/util/must"
|
"tailscale.com/util/must"
|
||||||
"tailscale.com/util/syspolicy/policyclient"
|
"tailscale.com/util/syspolicy/policyclient"
|
||||||
"tailscale.com/wgengine"
|
"tailscale.com/wgengine"
|
||||||
|
"tailscale.com/wgengine/filter"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExpandProxyArg(t *testing.T) {
|
func TestExpandProxyArg(t *testing.T) {
|
||||||
@@ -768,6 +771,156 @@ func TestServeHTTPProxyHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServeHTTPProxyGrantHeader(t *testing.T) {
|
||||||
|
b := newTestBackend(t)
|
||||||
|
|
||||||
|
nm := b.NetMap()
|
||||||
|
matches, err := filter.MatchesFromFilterRules([]tailcfg.FilterRule{
|
||||||
|
{
|
||||||
|
SrcIPs: []string{"100.150.151.152"},
|
||||||
|
CapGrant: []tailcfg.CapGrant{{
|
||||||
|
Dsts: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("100.150.151.151/32"),
|
||||||
|
},
|
||||||
|
CapMap: tailcfg.PeerCapMap{
|
||||||
|
"example.com/cap/interesting": []tailcfg.RawMessage{
|
||||||
|
`{"role": "🐿"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SrcIPs: []string{"100.150.151.153"},
|
||||||
|
CapGrant: []tailcfg.CapGrant{{
|
||||||
|
Dsts: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("100.150.151.151/32"),
|
||||||
|
},
|
||||||
|
CapMap: tailcfg.PeerCapMap{
|
||||||
|
"example.com/cap/boring": []tailcfg.RawMessage{
|
||||||
|
`{"role": "Viewer"}`,
|
||||||
|
},
|
||||||
|
"example.com/cap/irrelevant": []tailcfg.RawMessage{
|
||||||
|
`{"role": "Editor"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nm.PacketFilter = matches
|
||||||
|
b.SetControlClientStatus(nil, controlclient.Status{NetMap: nm})
|
||||||
|
|
||||||
|
// Start test serve endpoint.
|
||||||
|
testServ := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Piping all the headers through the response writer
|
||||||
|
// so we can check their values in tests below.
|
||||||
|
for key, val := range r.Header {
|
||||||
|
w.Header().Add(key, strings.Join(val, ","))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
))
|
||||||
|
defer testServ.Close()
|
||||||
|
|
||||||
|
conf := &ipn.ServeConfig{
|
||||||
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||||
|
"example.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||||
|
"/": {
|
||||||
|
Proxy: testServ.URL,
|
||||||
|
UserCaps: []tailcfg.PeerCapability{"example.com/cap/interesting", "example.com/cap/boring"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := b.SetServeConfig(conf, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type headerCheck struct {
|
||||||
|
header string
|
||||||
|
want string
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
srcIP string
|
||||||
|
wantHeaders []headerCheck
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "request-from-user-within-tailnet",
|
||||||
|
srcIP: "100.150.151.152",
|
||||||
|
wantHeaders: []headerCheck{
|
||||||
|
{"X-Forwarded-Proto", "https"},
|
||||||
|
{"X-Forwarded-For", "100.150.151.152"},
|
||||||
|
{"Tailscale-User-Login", "someone@example.com"},
|
||||||
|
{"Tailscale-User-Name", "Some One"},
|
||||||
|
{"Tailscale-User-Profile-Pic", "https://example.com/photo.jpg"},
|
||||||
|
{"Tailscale-Headers-Info", "https://tailscale.com/s/serve-headers"},
|
||||||
|
{"Tailscale-User-Capabilities", `{"example.com/cap/interesting":[{"role":"🐿"}]}`},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "request-from-tagged-node-within-tailnet",
|
||||||
|
srcIP: "100.150.151.153",
|
||||||
|
wantHeaders: []headerCheck{
|
||||||
|
{"X-Forwarded-Proto", "https"},
|
||||||
|
{"X-Forwarded-For", "100.150.151.153"},
|
||||||
|
{"Tailscale-User-Login", ""},
|
||||||
|
{"Tailscale-User-Name", ""},
|
||||||
|
{"Tailscale-User-Profile-Pic", ""},
|
||||||
|
{"Tailscale-Headers-Info", ""},
|
||||||
|
{"Tailscale-User-Capabilities", `{"example.com/cap/boring":[{"role":"Viewer"}]}`},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "request-from-outside-tailnet",
|
||||||
|
srcIP: "100.160.161.162",
|
||||||
|
wantHeaders: []headerCheck{
|
||||||
|
{"X-Forwarded-Proto", "https"},
|
||||||
|
{"X-Forwarded-For", "100.160.161.162"},
|
||||||
|
{"Tailscale-User-Login", ""},
|
||||||
|
{"Tailscale-User-Name", ""},
|
||||||
|
{"Tailscale-User-Profile-Pic", ""},
|
||||||
|
{"Tailscale-Headers-Info", ""},
|
||||||
|
{"Tailscale-User-Capabilities", ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Path: "/"},
|
||||||
|
TLS: &tls.ConnectionState{ServerName: "example.ts.net"},
|
||||||
|
}
|
||||||
|
req = req.WithContext(serveHTTPContextKey.WithValue(req.Context(), &serveHTTPContext{
|
||||||
|
DestPort: 443,
|
||||||
|
SrcAddr: netip.MustParseAddrPort(tt.srcIP + ":1234"), // random src port for tests
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
b.serveWebHandler(w, req)
|
||||||
|
|
||||||
|
// Verify the headers. The contract with users is that identity and grant headers containing non-ASCII
|
||||||
|
// UTF-8 characters will be Q-encoded.
|
||||||
|
h := w.Result().Header
|
||||||
|
dec := new(mime.WordDecoder)
|
||||||
|
for _, c := range tt.wantHeaders {
|
||||||
|
maybeEncoded := h.Get(c.header)
|
||||||
|
got, err := dec.DecodeHeader(maybeEncoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("invalid %q header; failed to decode: %v", maybeEncoded, err)
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("invalid %q header; want=%q, got=%q", c.header, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Test_reverseProxyConfiguration(t *testing.T) {
|
func Test_reverseProxyConfiguration(t *testing.T) {
|
||||||
b := newTestBackend(t)
|
b := newTestBackend(t)
|
||||||
type test struct {
|
type test struct {
|
||||||
@@ -926,6 +1079,9 @@ func newTestBackend(t *testing.T, opts ...any) *LocalBackend {
|
|||||||
b.currentNode().SetNetMap(&netmap.NetworkMap{
|
b.currentNode().SetNetMap(&netmap.NetworkMap{
|
||||||
SelfNode: (&tailcfg.Node{
|
SelfNode: (&tailcfg.Node{
|
||||||
Name: "example.ts.net",
|
Name: "example.ts.net",
|
||||||
|
Addresses: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("100.150.151.151/32"),
|
||||||
|
},
|
||||||
}).View(),
|
}).View(),
|
||||||
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfileView{
|
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfileView{
|
||||||
tailcfg.UserID(1): (&tailcfg.UserProfile{
|
tailcfg.UserID(1): (&tailcfg.UserProfile{
|
||||||
@@ -1171,3 +1327,89 @@ func TestServeGRPCProxy(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSerialisePeerCapMap(t *testing.T) {
|
||||||
|
var tests = []struct {
|
||||||
|
name string
|
||||||
|
capMap tailcfg.PeerCapMap
|
||||||
|
maxNumBytes int
|
||||||
|
wantOneOfSerialized []string
|
||||||
|
wantTruncated bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty cap map",
|
||||||
|
capMap: tailcfg.PeerCapMap{},
|
||||||
|
maxNumBytes: 50,
|
||||||
|
wantOneOfSerialized: []string{"{}"},
|
||||||
|
wantTruncated: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cap map with one capability",
|
||||||
|
capMap: tailcfg.PeerCapMap{
|
||||||
|
"tailscale.com/cap/kubernetes": []tailcfg.RawMessage{
|
||||||
|
`{"impersonate": {"groups": ["tailnet-readers"]}}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
maxNumBytes: 50,
|
||||||
|
wantOneOfSerialized: []string{
|
||||||
|
`{"tailscale.com/cap/kubernetes":[{"impersonate":{"groups":["tailnet-readers"]}}]}`,
|
||||||
|
},
|
||||||
|
wantTruncated: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cap map with two capabilities",
|
||||||
|
capMap: tailcfg.PeerCapMap{
|
||||||
|
"foo.com/cap/something": []tailcfg.RawMessage{
|
||||||
|
`{"role": "Admin"}`,
|
||||||
|
},
|
||||||
|
"bar.com/cap/other-thing": []tailcfg.RawMessage{
|
||||||
|
`{"role": "Viewer"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
maxNumBytes: 50,
|
||||||
|
// Both cap map entries will be included, but they could appear in any order.
|
||||||
|
wantOneOfSerialized: []string{
|
||||||
|
`{"foo.com/cap/something":[{"role":"Admin"}],"bar.com/cap/other-thing":[{"role":"Viewer"}]}`,
|
||||||
|
`{"bar.com/cap/other-thing":[{"role":"Viewer"}],"foo.com/cap/something":[{"role":"Admin"}]}`,
|
||||||
|
},
|
||||||
|
wantTruncated: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cap map that should be truncated to stay within size limits",
|
||||||
|
capMap: tailcfg.PeerCapMap{
|
||||||
|
"foo.com/cap/something": []tailcfg.RawMessage{
|
||||||
|
`{"role": "Admin"}`,
|
||||||
|
},
|
||||||
|
"bar.com/cap/other-thing": []tailcfg.RawMessage{
|
||||||
|
`{"role": "Viewer"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
maxNumBytes: 40,
|
||||||
|
// Only one cap map entry will be included, but we don't know which one.
|
||||||
|
wantOneOfSerialized: []string{
|
||||||
|
`{"foo.com/cap/something":[{"role":"Admin"}]}`,
|
||||||
|
`{"bar.com/cap/other-thing":[{"role":"Viewer"}]}`,
|
||||||
|
},
|
||||||
|
wantTruncated: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotSerialized, gotCapped, err := serializeUpToNBytes(tt.capMap, tt.maxNumBytes)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotCapped != tt.wantTruncated {
|
||||||
|
t.Errorf("got %t, want %t", gotCapped, tt.wantTruncated)
|
||||||
|
}
|
||||||
|
for _, wantSerialized := range tt.wantOneOfSerialized {
|
||||||
|
if gotSerialized == wantSerialized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("want one of %v, got %q", tt.wantOneOfSerialized, gotSerialized)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ type HTTPHandler struct {
|
|||||||
|
|
||||||
Text string `json:",omitempty"` // plaintext to serve (primarily for testing)
|
Text string `json:",omitempty"` // plaintext to serve (primarily for testing)
|
||||||
|
|
||||||
|
UserCaps []tailcfg.PeerCapability `json:",omitempty"` // peer capabilities to forward in grant header, e.g. example.com/cap/mon
|
||||||
|
|
||||||
// TODO(bradfitz): bool to not enumerate directories? TTL on mapping for
|
// TODO(bradfitz): bool to not enumerate directories? TTL on mapping for
|
||||||
// temporary ones? Error codes? Redirects?
|
// temporary ones? Error codes? Redirects?
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user