Files
tailscale/cmd/sniproxy/sniproxy.go
T
Brad Fitzpatrick ab4262680b ipn, ipn/ipnlocal: remove Notify.NetMap, size initial status to subscription
Nothing reads ipn.Notify.NetMap anymore. The previous commit removed
its runtime (non-initial) emission, and every first-party client is
also off the initial one: the Win32 and WinUI GUIs and the Apple
bridge subscribe with InitialStatus or InitialState plus peer deltas,
Android no longer uses it, and the remaining in-tree subscribers that set
NotifyInitialNetMap (sniproxy and the kube helpers) only did so to get
the initial Notify.SelfChange and discarded the netmap that tailscaled
built, encoded, and shipped for them.

Delete the Notify.NetMap field and the NotifyInitialNetMap bit. The
bit value stays reserved under the name ObsoleteNotifyInitialNetMap and
ValidateNotifyWatchOpt rejects subscriptions that set it, like the
NotifyRateLimit bit removed in the previous commit. NotifyNoNetMap
remains accepted as a no-op because shipping GUIs still set it.

The blessed way to seed a watcher's view is NotifyInitialStatus, but
it unconditionally built O(peers) status entries, which is exactly the
waste this series is deleting for watchers that only care about the
self node. Size the initial status to the subscription instead:
Status.Peer is only populated when the watcher also set
NotifyPeerChanges or NotifyPeerPatches, since only peer-delta
subscribers need a peer baseline to apply deltas to. That matches its
existing first-party users (containerboot and the WinUI GUI both pair
InitialStatus with peer bits).

Migrate sniproxy and the kube helpers to NotifyInitialStatus: they
seed from InitialStatus.Self and react to the (ungated) runtime
Notify.SelfChange messages after that, so their initial message now
carries one PeerStatus instead of a full netmap.

Also drop doc comment references to LocalClient.NetMap, a method that
does not exist; on-demand fetches go through other LocalAPI methods
such as LocalClient.Status.

Updates #12542

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I242992a744c0ffd0be6f27e8c735aa69d5b23b5e
2026-09-14 09:37:30 -07:00

294 lines
8.4 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// The sniproxy is an outbound SNI proxy. It receives TLS connections over
// Tailscale on one or more TCP ports and sends them out to the same SNI
// hostname & port on the internet. It can optionally forward one or more
// TCP ports to a specific destination. It only does TCP.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net"
"net/http"
"net/netip"
"os"
"sort"
"strconv"
"strings"
"github.com/peterbourgon/ff/v3"
"tailscale.com/client/local"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/tsweb"
"tailscale.com/types/appctype"
"tailscale.com/types/ipproto"
"tailscale.com/types/nettype"
"tailscale.com/util/mak"
)
const configCapKey = "tailscale.com/sniproxy"
// portForward is the state for a single port forwarding entry, as passed to the --forward flag.
type portForward struct {
Port int
Proto string
Destination string
}
// parseForward takes a proto/port/destination tuple as an input, as would be passed
// to the --forward command line flag, and returns a *portForward struct of those parameters.
func parseForward(value string) (*portForward, error) {
parts := strings.Split(value, "/")
if len(parts) != 3 {
return nil, errors.New("cannot parse: " + value)
}
proto := parts[0]
if proto != "tcp" {
return nil, errors.New("unsupported forwarding protocol: " + proto)
}
port, err := strconv.ParseUint(parts[1], 10, 16)
if err != nil {
return nil, errors.New("bad forwarding port: " + parts[1])
}
host := parts[2]
if host == "" {
return nil, errors.New("bad destination: " + value)
}
return &portForward{Port: int(port), Proto: proto, Destination: host}, nil
}
func main() {
// Parse flags
fs := flag.NewFlagSet("sniproxy", flag.ContinueOnError)
var (
ports = fs.String("ports", "443", "comma-separated list of ports to proxy")
forwards = fs.String("forwards", "", "comma-separated list of ports to transparently forward, protocol/number/destination. For example, --forwards=tcp/22/github.com,tcp/5432/sql.example.com")
wgPort = fs.Int("wg-listen-port", 0, "UDP port to listen on for WireGuard and peer-to-peer traffic; 0 means automatically select")
promoteHTTPS = fs.Bool("promote-https", true, "promote HTTP to HTTPS")
debugPort = fs.Int("debug-port", 8893, "Listening port for debug/metrics endpoint")
hostname = fs.String("hostname", "", "Hostname to register the service under")
)
err := ff.Parse(fs, os.Args[1:], ff.WithEnvVarPrefix("TS_APPC"))
if err != nil {
log.Fatal("ff.Parse")
}
var ts tsnet.Server
defer ts.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
run(ctx, &ts, *wgPort, *hostname, *promoteHTTPS, *debugPort, *ports, *forwards)
}
// run actually runs the sniproxy. Its separate from main() to assist in testing.
func run(ctx context.Context, ts *tsnet.Server, wgPort int, hostname string, promoteHTTPS bool, debugPort int, ports, forwards string) {
// Wire up Tailscale node + app connector server
hostinfo.SetApp("sniproxy")
var s sniproxy
s.ts = ts
s.ts.Port = uint16(wgPort)
s.ts.Hostname = hostname
lc, err := s.ts.LocalClient()
if err != nil {
log.Fatalf("LocalClient() failed: %v", err)
}
s.lc = lc
s.ts.RegisterFallbackTCPHandler(s.srv.HandleTCPFlow)
// Start special-purpose listeners: dns, http promotion, debug server
ln, err := s.ts.Listen("udp", ":53")
if err != nil {
log.Fatalf("failed listening on port 53: %v", err)
}
defer ln.Close()
go s.serveDNS(ln)
if promoteHTTPS {
ln, err := s.ts.Listen("tcp", ":80")
if err != nil {
log.Fatalf("failed listening on port 80: %v", err)
}
defer ln.Close()
log.Printf("Promoting HTTP to HTTPS ...")
go s.promoteHTTPS(ln)
}
if debugPort != 0 {
mux := http.NewServeMux()
tsweb.Debugger(mux)
dln, err := s.ts.Listen("tcp", fmt.Sprintf(":%d", debugPort))
if err != nil {
log.Fatalf("failed listening on debug port: %v", err)
}
defer dln.Close()
go func() {
log.Fatalf("debug serve: %v", http.Serve(dln, mux))
}()
}
// Finally, start mainloop to configure app connector based on information
// in the self node's CapMap. The initial status carries the current
// self node; subsequent self changes arrive via Notify.SelfChange.
bus, err := lc.WatchIPNBus(ctx, ipn.NotifyWatchEngineUpdates|ipn.NotifyInitialStatus)
if err != nil {
log.Fatalf("watching IPN bus: %v", err)
}
defer bus.Close()
for {
msg, err := bus.Next()
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
log.Fatalf("reading IPN bus: %v", err)
}
var capMap tailcfg.NodeCapMap
if self := msg.SelfChange; self != nil {
capMap = self.CapMap
} else if st := msg.InitialStatus; st != nil && st.Self != nil {
capMap = st.Self.CapMap
} else {
continue
}
var c appctype.AppConnectorConfig
nmConf, err := tailcfg.UnmarshalNodeCapJSON[appctype.AppConnectorConfig](capMap, configCapKey)
if err != nil {
log.Printf("failed to read app connector configuration from coordination server: %v", err)
} else if len(nmConf) > 0 {
c = nmConf[0]
}
if c.AdvertiseRoutes {
if err := s.advertiseRoutesFromConfig(ctx, &c); err != nil {
log.Printf("failed to advertise routes: %v", err)
}
}
// Backwards compatibility: combine any configuration from control with flags specified
// on the command line. This is intentionally done after we advertise any routes
// because its never correct to advertise the nodes native IP addresses.
s.mergeConfigFromFlags(&c, ports, forwards)
s.srv.Configure(&c)
}
}
type sniproxy struct {
srv Server
ts *tsnet.Server
lc *local.Client
}
func (s *sniproxy) advertiseRoutesFromConfig(ctx context.Context, c *appctype.AppConnectorConfig) error {
// Collect the set of addresses to advertise, using a map
// to avoid duplicate entries.
addrs := map[netip.Addr]struct{}{}
for _, c := range c.SNIProxy {
for _, ip := range c.Addrs {
addrs[ip] = struct{}{}
}
}
for _, c := range c.DNAT {
for _, ip := range c.Addrs {
addrs[ip] = struct{}{}
}
}
var routes []netip.Prefix
for a := range addrs {
routes = append(routes, netip.PrefixFrom(a, a.BitLen()))
}
sort.SliceStable(routes, func(i, j int) bool {
return routes[i].Addr().Less(routes[j].Addr()) // determinism r us
})
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
AdvertiseRoutes: routes,
},
AdvertiseRoutesSet: true,
})
return err
}
func (s *sniproxy) mergeConfigFromFlags(out *appctype.AppConnectorConfig, ports, forwards string) {
ip4, ip6 := s.ts.TailscaleIPs()
sniConfigFromFlags := appctype.SNIProxyConfig{
Addrs: []netip.Addr{ip4, ip6},
}
if ports != "" {
for portStr := range strings.SplitSeq(ports, ",") {
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
log.Fatalf("invalid port: %s", portStr)
}
sniConfigFromFlags.IP = append(sniConfigFromFlags.IP, tailcfg.ProtoPortRange{
Proto: int(ipproto.TCP),
Ports: tailcfg.PortRange{First: uint16(port), Last: uint16(port)},
})
}
}
var forwardConfigFromFlags []appctype.DNATConfig
for forwStr := range strings.SplitSeq(forwards, ",") {
if forwStr == "" {
continue
}
forw, err := parseForward(forwStr)
if err != nil {
log.Printf("invalid forwarding spec: %v", err)
continue
}
forwardConfigFromFlags = append(forwardConfigFromFlags, appctype.DNATConfig{
Addrs: []netip.Addr{ip4, ip6},
To: []string{forw.Destination},
IP: []tailcfg.ProtoPortRange{
{
Proto: int(ipproto.TCP),
Ports: tailcfg.PortRange{First: uint16(forw.Port), Last: uint16(forw.Port)},
},
},
})
}
if len(forwardConfigFromFlags) == 0 && len(sniConfigFromFlags.IP) == 0 {
return // no config specified on the command line
}
mak.Set(&out.SNIProxy, "flags", sniConfigFromFlags)
for i, forward := range forwardConfigFromFlags {
mak.Set(&out.DNAT, appctype.ConfigID(fmt.Sprintf("flags_%d", i)), forward)
}
}
func (s *sniproxy) serveDNS(ln net.Listener) {
for {
c, err := ln.Accept()
if err != nil {
log.Printf("serveDNS accept: %v", err)
return
}
go s.srv.HandleDNS(c.(nettype.ConnPacketConn))
}
}
func (s *sniproxy) promoteHTTPS(ln net.Listener) {
err := http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusFound)
}))
log.Fatalf("promoteHTTPS http.Serve: %v", err)
}