mirror of
https://github.com/tailscale/tailscale.git
synced 2026-10-08 19:31:47 -04:00
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
294 lines
8.4 KiB
Go
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)
|
|
}
|