292 lines
8.4 KiB
Go
292 lines
8.4 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// 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/tailscale"
|
|
"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 netmap.
|
|
// We set the NotifyInitialNetMap flag so we will always get woken with the
|
|
// current netmap, before only being woken on changes.
|
|
bus, err := lc.WatchIPNBus(ctx, ipn.NotifyWatchEngineUpdates|ipn.NotifyInitialNetMap|ipn.NotifyNoPrivateKeys)
|
|
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)
|
|
}
|
|
|
|
// NetMap contains app-connector configuration
|
|
if nm := msg.NetMap; nm != nil && nm.SelfNode.Valid() {
|
|
sn := nm.SelfNode.AsStruct()
|
|
|
|
var c appctype.AppConnectorConfig
|
|
nmConf, err := tailcfg.UnmarshalNodeCapJSON[appctype.AppConnectorConfig](sn.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 *tailscale.LocalClient
|
|
}
|
|
|
|
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.Split(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.Split(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)
|
|
}
|