diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index 48df748b4..eb0e41748 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -76,13 +76,20 @@ func (src *ServeConfig) Clone() *ServeConfig { dst.Web[k] = v.Clone() } } + if dst.AllowIngress != nil { + dst.AllowIngress = map[HostPort]bool{} + for k, v := range src.AllowIngress { + dst.AllowIngress[k] = v + } + } return dst } // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _ServeConfigCloneNeedsRegeneration = ServeConfig(struct { - TCP map[int]*TCPPortHandler - Web map[HostPort]*WebServerConfig + TCP map[int]*TCPPortHandler + Web map[HostPort]*WebServerConfig + AllowIngress map[HostPort]bool }{}) // Clone makes a deep copy of TCPPortHandler. diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 634158341..e3a65e187 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -176,10 +176,15 @@ func (v ServeConfigView) Web() views.MapFn[HostPort, *WebServerConfig, WebServer }) } +func (v ServeConfigView) AllowIngress() views.Map[HostPort, bool] { + return views.MapOf(v.ж.AllowIngress) +} + // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _ServeConfigViewNeedsRegeneration = ServeConfig(struct { - TCP map[int]*TCPPortHandler - Web map[HostPort]*WebServerConfig + TCP map[int]*TCPPortHandler + Web map[HostPort]*WebServerConfig + AllowIngress map[HostPort]bool }{}) // View returns a readonly view of TCPPortHandler. diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go index 21bfe3937..441080162 100644 --- a/ipn/ipnlocal/peerapi.go +++ b/ipn/ipnlocal/peerapi.go @@ -34,6 +34,7 @@ import ( "github.com/kortschak/wol" "golang.org/x/net/dns/dnsmessage" "tailscale.com/client/tailscale/apitype" + "tailscale.com/envknob" "tailscale.com/health" "tailscale.com/hostinfo" "tailscale.com/ipn" @@ -572,6 +573,9 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case "/v0/interfaces": h.handleServeInterfaces(w, r) return + case "/v0/ingress": + h.handleServeIngress(w, r) + return } who := h.peerUser.DisplayName fmt.Fprintf(w, ` @@ -586,6 +590,63 @@ This is my Tailscale device. Your device is %v. } } +func (h *peerAPIHandler) handleServeIngress(w http.ResponseWriter, r *http.Request) { + // http.Errors only useful if hitting endpoint manually + // otherwise rely on log lines when debugging ingress connections + // as connection is hijacked for bidi and is encrypted tls + if !h.canIngress() { + h.logf("ingress: denied; no ingress cap from %v", h.remoteAddr) + http.Error(w, "denied; no ingress cap", http.StatusForbidden) + return + } + logAndError := func(code int, publicMsg string) { + h.logf("ingress: bad request from %v: %s", h.remoteAddr, publicMsg) + http.Error(w, publicMsg, http.StatusMethodNotAllowed) + } + bad := func(publicMsg string) { + logAndError(http.StatusBadRequest, publicMsg) + } + if r.Method != "POST" { + logAndError(http.StatusMethodNotAllowed, "only POST allowed") + return + } + srcAddrStr := r.Header.Get("Tailscale-Ingress-Src") + if srcAddrStr == "" { + bad("Tailscale-Ingress-Src header not set") + return + } + srcAddr, err := netip.ParseAddrPort(srcAddrStr) + if err != nil { + bad("Tailscale-Ingress-Src header invalid; want ip:port") + return + } + target := r.Header.Get("Tailscale-Ingress-Target") + if target == "" { + bad("Tailscale-Target-Target header not set") + return + } + if _, _, err := net.SplitHostPort(target); err != nil { + bad("Tailscale-Target-Target header invalid; want host:port") + return + } + + getConn := func() (net.Conn, bool) { + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + h.logf("ingress: failed hijacking conn") + http.Error(w, "failed hijacking conn", http.StatusInternalServerError) + return nil, false + } + io.WriteString(conn, "HTTP/1.1 101 Switching Protocols\r\n\r\n") + return conn, true + } + sendRST := func() { + http.Error(w, "denied", http.StatusForbidden) + } + + h.ps.b.HandleIngressTCPConn(h.peerNode, ipn.HostPort(target), srcAddr, getConn, sendRST) +} + func (h *peerAPIHandler) handleServeInterfaces(w http.ResponseWriter, r *http.Request) { if !h.canDebug() { http.Error(w, "denied; no debug access", http.StatusForbidden) @@ -694,6 +755,13 @@ func (h *peerAPIHandler) canWakeOnLAN() bool { return h.isSelf || h.peerHasCap(tailcfg.CapabilityWakeOnLAN) } +var allowSelfIngress = envknob.RegisterBool("TS_ALLOW_SELF_INGRESS") + +// canIngress reports whether h can send ingress requests to this node. +func (h *peerAPIHandler) canIngress() bool { + return h.peerHasCap(tailcfg.CapabilityIngress) || (allowSelfIngress() && h.isSelf) +} + func (h *peerAPIHandler) peerHasCap(wantCap string) bool { for _, hasCap := range h.ps.b.PeerCaps(h.remoteAddr.Addr()) { if hasCap == wantCap { diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index 71a11cca1..fd73c7aba 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -16,11 +16,13 @@ import ( "net/netip" "net/url" pathpkg "path" + "strconv" "strings" "time" "tailscale.com/ipn" "tailscale.com/net/netutil" + "tailscale.com/tailcfg" ) // serveHTTPContextKey is the context.Value key for a *serveHTTPContext. @@ -31,6 +33,40 @@ type serveHTTPContext struct { DestPort uint16 } +func (b *LocalBackend) HandleIngressTCPConn(ingressPeer *tailcfg.Node, target ipn.HostPort, srcAddr netip.AddrPort, getConn func() (net.Conn, bool), sendRST func()) { + b.mu.Lock() + sc := b.serveConfig + b.mu.Unlock() + + if !sc.Valid() { + b.logf("localbackend: got ingress conn w/o serveConfig; rejecting") + sendRST() + return + } + + if !sc.AllowIngress().Get(target) { + b.logf("localbackend: got ingress conn for unconfigured %q; rejecting", target) + sendRST() + return + } + + _, port, err := net.SplitHostPort(string(target)) + if err != nil { + b.logf("localbackend: got ingress conn for bad target %q; rejecting", target) + sendRST() + return + } + port16, err := strconv.ParseUint(port, 10, 16) + if err != nil { + b.logf("localbackend: got ingress conn for bad target %q; rejecting", target) + sendRST() + return + } + // TODO(bradfitz): pass ingressPeer etc in context to HandleInterceptedTCPConn, + // extend serveHTTPContext or similar. + b.HandleInterceptedTCPConn(uint16(port16), srcAddr, getConn, sendRST) +} + func (b *LocalBackend) HandleInterceptedTCPConn(dport uint16, srcAddr netip.AddrPort, getConn func() (net.Conn, bool), sendRST func()) { b.mu.Lock() sc := b.serveConfig diff --git a/ipn/store.go b/ipn/store.go index 85cf0a8e5..840978ce1 100644 --- a/ipn/store.go +++ b/ipn/store.go @@ -81,6 +81,10 @@ type ServeConfig struct { // Web maps from "$SNI_NAME:$PORT" to a set of HTTP handlers // keyed by mount point ("/", "/foo", etc) Web map[HostPort]*WebServerConfig `json:",omitempty"` + + // AllowIngress is the set of SNI:port values for which ingress + // traffic is allowed, from trusted ingress peers. + AllowIngress map[HostPort]bool } // HostPort is an SNI name and port number, joined by a colon. diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 1a1da587f..5f7c0e01f 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -1672,6 +1672,8 @@ const ( CapabilityDebugPeer = "https://tailscale.com/cap/debug-peer" // CapabilityWakeOnLAN grants the ability to send a Wake-On-LAN packet. CapabilityWakeOnLAN = "https://tailscale.com/cap/wake-on-lan" + // CapabilityIngress grants the ability for a peer to send ingress traffic. + CapabilityIngress = "https://tailscale.com/cap/ingress" ) // SetDNSRequest is a request to add a DNS record.