You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
710 lines
20 KiB
710 lines
20 KiB
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
// Package conn25 registers the conn25 feature and implements its associated ipnext.Extension.
|
|
// conn25 will be an app connector like feature that routes traffic for configured domains via
|
|
// connector devices and avoids the "too many routes" pitfall of app connector. It is currently
|
|
// (2026-02-04) some peer API routes for clients to tell connectors about their desired routing.
|
|
package conn25
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"sync"
|
|
|
|
"go4.org/netipx"
|
|
"golang.org/x/net/dns/dnsmessage"
|
|
"tailscale.com/appc"
|
|
"tailscale.com/feature"
|
|
"tailscale.com/ipn/ipnext"
|
|
"tailscale.com/ipn/ipnlocal"
|
|
"tailscale.com/net/dns"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/appctype"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/util/dnsname"
|
|
"tailscale.com/util/mak"
|
|
"tailscale.com/util/set"
|
|
)
|
|
|
|
// featureName is the name of the feature implemented by this package.
|
|
// It is also the [extension] name and the log prefix.
|
|
const featureName = "conn25"
|
|
|
|
const maxBodyBytes = 1024 * 1024
|
|
|
|
// jsonDecode decodes all of a io.ReadCloser (eg an http.Request Body) into one pointer with best practices.
|
|
// It limits the size of bytes it will read.
|
|
// It either decodes all of the bytes into the pointer, or errors (unlike json.Decoder.Decode).
|
|
// It closes the ReadCloser after reading.
|
|
func jsonDecode(target any, rc io.ReadCloser) error {
|
|
defer rc.Close()
|
|
respBs, err := io.ReadAll(io.LimitReader(rc, maxBodyBytes+1))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = json.Unmarshal(respBs, &target)
|
|
return err
|
|
}
|
|
|
|
func init() {
|
|
feature.Register(featureName)
|
|
ipnext.RegisterExtension(featureName, func(logf logger.Logf, sb ipnext.SafeBackend) (ipnext.Extension, error) {
|
|
return &extension{
|
|
conn25: newConn25(logger.WithPrefix(logf, "conn25: ")),
|
|
backend: sb,
|
|
}, nil
|
|
})
|
|
ipnlocal.RegisterPeerAPIHandler("/v0/connector/transit-ip", handleConnectorTransitIP)
|
|
}
|
|
|
|
func handleConnectorTransitIP(h ipnlocal.PeerAPIHandler, w http.ResponseWriter, r *http.Request) {
|
|
e, ok := ipnlocal.GetExt[*extension](h.LocalBackend())
|
|
if !ok {
|
|
http.Error(w, "miswired", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
e.handleConnectorTransitIP(h, w, r)
|
|
}
|
|
|
|
// extension is an [ipnext.Extension] managing the connector on platforms
|
|
// that import this package.
|
|
type extension struct {
|
|
conn25 *Conn25 // safe for concurrent access and only set at creation
|
|
backend ipnext.SafeBackend // safe for concurrent access and only set at creation
|
|
|
|
host ipnext.Host // set in Init, read-only after
|
|
ctxCancel context.CancelCauseFunc // cancels sendLoop goroutine
|
|
|
|
mu sync.Mutex // protects the fields below
|
|
isDNSHookRegistered bool
|
|
}
|
|
|
|
// Name implements [ipnext.Extension].
|
|
func (e *extension) Name() string {
|
|
return featureName
|
|
}
|
|
|
|
// Init implements [ipnext.Extension].
|
|
func (e *extension) Init(host ipnext.Host) error {
|
|
//Init only once
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
if e.ctxCancel != nil {
|
|
return nil
|
|
}
|
|
e.host = host
|
|
host.Hooks().OnSelfChange.Add(e.onSelfChange)
|
|
ctx, cancel := context.WithCancelCause(context.Background())
|
|
e.ctxCancel = cancel
|
|
go e.sendLoop(ctx)
|
|
return nil
|
|
}
|
|
|
|
// Shutdown implements [ipnlocal.Extension].
|
|
func (e *extension) Shutdown() error {
|
|
if e.ctxCancel != nil {
|
|
e.ctxCancel(errors.New("extension shutdown"))
|
|
}
|
|
if e.conn25 != nil {
|
|
close(e.conn25.client.addrsCh)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *extension) handleConnectorTransitIP(h ipnlocal.PeerAPIHandler, w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
if r.Method != "POST" {
|
|
http.Error(w, "Method should be POST", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req ConnectorTransitIPRequest
|
|
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes+1)).Decode(&req)
|
|
if err != nil {
|
|
http.Error(w, "Error decoding JSON", http.StatusBadRequest)
|
|
return
|
|
}
|
|
resp := e.conn25.handleConnectorTransitIPRequest(h.Peer().ID(), req)
|
|
bs, err := json.Marshal(resp)
|
|
if err != nil {
|
|
http.Error(w, "Error encoding JSON", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Write(bs)
|
|
}
|
|
|
|
func (e *extension) onSelfChange(selfNode tailcfg.NodeView) {
|
|
err := e.conn25.reconfig(selfNode)
|
|
if err != nil {
|
|
e.conn25.client.logf("error during Reconfig onSelfChange: %v", err)
|
|
return
|
|
}
|
|
|
|
if e.conn25.isConfigured() {
|
|
err = e.registerDNSHook()
|
|
} else {
|
|
err = e.unregisterDNSHook()
|
|
}
|
|
if err != nil {
|
|
e.conn25.client.logf("error managing DNS hook onSelfChange: %v", err)
|
|
}
|
|
}
|
|
|
|
func (e *extension) registerDNSHook() error {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
if e.isDNSHookRegistered {
|
|
return nil
|
|
}
|
|
err := e.setDNSHookLocked(e.conn25.mapDNSResponse)
|
|
if err == nil {
|
|
e.isDNSHookRegistered = true
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (e *extension) unregisterDNSHook() error {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
if !e.isDNSHookRegistered {
|
|
return nil
|
|
}
|
|
err := e.setDNSHookLocked(nil)
|
|
if err == nil {
|
|
e.isDNSHookRegistered = false
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (e *extension) setDNSHookLocked(fx dns.ResponseMapper) error {
|
|
dnsManager, ok := e.backend.Sys().DNSManager.GetOK()
|
|
if !ok || dnsManager == nil {
|
|
return errors.New("couldn't get DNSManager from sys")
|
|
}
|
|
dnsManager.SetQueryResponseMapper(fx)
|
|
return nil
|
|
}
|
|
|
|
type appAddr struct {
|
|
app string
|
|
addr netip.Addr
|
|
}
|
|
|
|
// Conn25 holds state for routing traffic for a domain via a connector.
|
|
type Conn25 struct {
|
|
client *client
|
|
connector *connector
|
|
}
|
|
|
|
func (c *Conn25) isConfigured() bool {
|
|
return c.client.isConfigured()
|
|
}
|
|
|
|
func newConn25(logf logger.Logf) *Conn25 {
|
|
c := &Conn25{
|
|
client: &client{
|
|
logf: logf,
|
|
addrsCh: make(chan addrs, 64),
|
|
},
|
|
connector: &connector{logf: logf},
|
|
}
|
|
return c
|
|
}
|
|
|
|
func ipSetFromIPRanges(rs []netipx.IPRange) (*netipx.IPSet, error) {
|
|
b := &netipx.IPSetBuilder{}
|
|
for _, r := range rs {
|
|
b.AddRange(r)
|
|
}
|
|
return b.IPSet()
|
|
}
|
|
|
|
func (c *Conn25) reconfig(selfNode tailcfg.NodeView) error {
|
|
cfg, err := configFromNodeView(selfNode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := c.client.reconfig(cfg); err != nil {
|
|
return err
|
|
}
|
|
if err := c.connector.reconfig(cfg); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mapDNSResponse parses and inspects the DNS response, and uses the
|
|
// contents to assign addresses for connecting. It does not yet modify
|
|
// the response.
|
|
func (c *Conn25) mapDNSResponse(buf []byte) []byte {
|
|
return c.client.mapDNSResponse(buf)
|
|
}
|
|
|
|
const dupeTransitIPMessage = "Duplicate transit address in ConnectorTransitIPRequest"
|
|
|
|
// handleConnectorTransitIPRequest creates a ConnectorTransitIPResponse in response to a ConnectorTransitIPRequest.
|
|
// It updates the connectors mapping of TransitIP->DestinationIP per peer (tailcfg.NodeID).
|
|
// If a peer has stored this mapping in the connector Conn25 will route traffic to TransitIPs to DestinationIPs for that peer.
|
|
func (c *Conn25) handleConnectorTransitIPRequest(nid tailcfg.NodeID, ctipr ConnectorTransitIPRequest) ConnectorTransitIPResponse {
|
|
resp := ConnectorTransitIPResponse{}
|
|
seen := map[netip.Addr]bool{}
|
|
for _, each := range ctipr.TransitIPs {
|
|
if seen[each.TransitIP] {
|
|
resp.TransitIPs = append(resp.TransitIPs, TransitIPResponse{
|
|
Code: OtherFailure,
|
|
Message: dupeTransitIPMessage,
|
|
})
|
|
continue
|
|
}
|
|
tipresp := c.connector.handleTransitIPRequest(nid, each)
|
|
seen[each.TransitIP] = true
|
|
resp.TransitIPs = append(resp.TransitIPs, tipresp)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func (s *connector) handleTransitIPRequest(nid tailcfg.NodeID, tipr TransitIPRequest) TransitIPResponse {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.transitIPs == nil {
|
|
s.transitIPs = make(map[tailcfg.NodeID]map[netip.Addr]appAddr)
|
|
}
|
|
peerMap, ok := s.transitIPs[nid]
|
|
if !ok {
|
|
peerMap = make(map[netip.Addr]appAddr)
|
|
s.transitIPs[nid] = peerMap
|
|
}
|
|
peerMap[tipr.TransitIP] = appAddr{addr: tipr.DestinationIP, app: tipr.App}
|
|
return TransitIPResponse{}
|
|
}
|
|
|
|
func (s *connector) transitIPTarget(nid tailcfg.NodeID, tip netip.Addr) netip.Addr {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.transitIPs[nid][tip].addr
|
|
}
|
|
|
|
// TransitIPRequest details a single TransitIP allocation request from a client to a
|
|
// connector.
|
|
type TransitIPRequest struct {
|
|
// TransitIP is the intermediate destination IP that will be received at this
|
|
// connector and will be replaced by DestinationIP when performing DNAT.
|
|
TransitIP netip.Addr `json:"transitIP,omitzero"`
|
|
|
|
// DestinationIP is the final destination IP that connections to the TransitIP
|
|
// should be mapped to when performing DNAT.
|
|
DestinationIP netip.Addr `json:"destinationIP,omitzero"`
|
|
|
|
// App is the name of the connector application from the tailnet
|
|
// configuration.
|
|
App string `json:"app,omitzero"`
|
|
}
|
|
|
|
// ConnectorTransitIPRequest is the request body for a PeerAPI request to
|
|
// /connector/transit-ip and can include zero or more TransitIP allocation requests.
|
|
type ConnectorTransitIPRequest struct {
|
|
// TransitIPs is the list of requested mappings.
|
|
TransitIPs []TransitIPRequest `json:"transitIPs,omitempty"`
|
|
}
|
|
|
|
// TransitIPResponseCode appears in TransitIPResponse and signifies success or failure status.
|
|
type TransitIPResponseCode int
|
|
|
|
const (
|
|
// OK indicates that the mapping was created as requested.
|
|
OK TransitIPResponseCode = 0
|
|
|
|
// OtherFailure indicates that the mapping failed for a reason that does not have
|
|
// another relevant [TransitIPResponsecode].
|
|
OtherFailure TransitIPResponseCode = 1
|
|
)
|
|
|
|
// TransitIPResponse is the response to a TransitIPRequest
|
|
type TransitIPResponse struct {
|
|
// Code is an error code indicating success or failure of the [TransitIPRequest].
|
|
Code TransitIPResponseCode `json:"code,omitzero"`
|
|
// Message is an error message explaining what happened, suitable for logging but
|
|
// not necessarily suitable for displaying in a UI to non-technical users. It
|
|
// should be empty when [Code] is [OK].
|
|
Message string `json:"message,omitzero"`
|
|
}
|
|
|
|
// ConnectorTransitIPResponse is the response to a ConnectorTransitIPRequest
|
|
type ConnectorTransitIPResponse struct {
|
|
// TransitIPs is the list of outcomes for each requested mapping. Elements
|
|
// correspond to the order of [ConnectorTransitIPRequest.TransitIPs].
|
|
TransitIPs []TransitIPResponse `json:"transitIPs,omitempty"`
|
|
}
|
|
|
|
const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental"
|
|
|
|
// config holds the config from the policy and lookups derived from that.
|
|
// config is not safe for concurrent use.
|
|
type config struct {
|
|
isConfigured bool
|
|
apps []appctype.Conn25Attr
|
|
appsByName map[string]appctype.Conn25Attr
|
|
appNamesByDomain map[dnsname.FQDN][]string
|
|
selfRoutedDomains set.Set[dnsname.FQDN]
|
|
}
|
|
|
|
func configFromNodeView(n tailcfg.NodeView) (config, error) {
|
|
apps, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.Conn25Attr](n.CapMap(), AppConnectorsExperimentalAttrName)
|
|
if err != nil {
|
|
return config{}, err
|
|
}
|
|
if len(apps) == 0 {
|
|
return config{}, nil
|
|
}
|
|
selfTags := set.SetOf(n.Tags().AsSlice())
|
|
cfg := config{
|
|
isConfigured: true,
|
|
apps: apps,
|
|
appsByName: map[string]appctype.Conn25Attr{},
|
|
appNamesByDomain: map[dnsname.FQDN][]string{},
|
|
selfRoutedDomains: set.Set[dnsname.FQDN]{},
|
|
}
|
|
for _, app := range apps {
|
|
selfMatchesTags := slices.ContainsFunc(app.Connectors, selfTags.Contains)
|
|
for _, d := range app.Domains {
|
|
fqdn, err := dnsname.ToFQDN(d)
|
|
if err != nil {
|
|
return config{}, err
|
|
}
|
|
mak.Set(&cfg.appNamesByDomain, fqdn, append(cfg.appNamesByDomain[fqdn], app.Name))
|
|
if selfMatchesTags {
|
|
cfg.selfRoutedDomains.Add(fqdn)
|
|
}
|
|
}
|
|
mak.Set(&cfg.appsByName, app.Name, app)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// client performs the conn25 functionality for clients of connectors
|
|
// It allocates magic and transit IP addresses and communicates them with
|
|
// connectors.
|
|
// It's safe for concurrent use.
|
|
type client struct {
|
|
logf logger.Logf
|
|
addrsCh chan addrs
|
|
|
|
mu sync.Mutex // protects the fields below
|
|
magicIPPool *ippool
|
|
transitIPPool *ippool
|
|
assignments addrAssignments
|
|
config config
|
|
}
|
|
|
|
func (c *client) isConfigured() bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.config.isConfigured
|
|
}
|
|
|
|
func (c *client) reconfig(newCfg config) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.config = newCfg
|
|
|
|
// TODO(fran) this is not the correct way to manage the pools and changes to the pools.
|
|
// We probably want to:
|
|
// * check the pools haven't changed
|
|
// * reset the whole connector if the pools change? or just if they've changed to exclude
|
|
// addresses we have in use?
|
|
// * have config separate from the apps for this (rather than multiple potentially conflicting places)
|
|
// but this works while we are just getting started here.
|
|
for _, app := range c.config.apps {
|
|
if c.magicIPPool != nil { // just take the first config and never reconfig
|
|
break
|
|
}
|
|
if app.MagicIPPool == nil {
|
|
continue
|
|
}
|
|
mipp, err := ipSetFromIPRanges(app.MagicIPPool)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tipp, err := ipSetFromIPRanges(app.TransitIPPool)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.magicIPPool = newIPPool(mipp)
|
|
c.transitIPPool = newIPPool(tipp)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *client) isConnectorDomain(domain dnsname.FQDN) bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
appNames, ok := c.config.appNamesByDomain[domain]
|
|
return ok && len(appNames) > 0
|
|
}
|
|
|
|
// reserveAddresses tries to make an assignment of addrs from the address pools
|
|
// for this domain+dst address, so that this client can use conn25 connectors.
|
|
// It checks that this domain should be routed and that this client is not itself a connector for the domain
|
|
// and generally if it is valid to make the assignment.
|
|
func (c *client) reserveAddresses(domain dnsname.FQDN, dst netip.Addr) (addrs, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if existing, ok := c.assignments.lookupByDomainDst(domain, dst); ok {
|
|
return existing, nil
|
|
}
|
|
appNames, _ := c.config.appNamesByDomain[domain]
|
|
// only reserve for first app
|
|
app := appNames[0]
|
|
mip, err := c.magicIPPool.next()
|
|
if err != nil {
|
|
return addrs{}, err
|
|
}
|
|
tip, err := c.transitIPPool.next()
|
|
if err != nil {
|
|
return addrs{}, err
|
|
}
|
|
as := addrs{
|
|
dst: dst,
|
|
magic: mip,
|
|
transit: tip,
|
|
app: app,
|
|
domain: domain,
|
|
}
|
|
if err := c.assignments.insert(as); err != nil {
|
|
return addrs{}, err
|
|
}
|
|
err = c.enqueueAddressAssignment(as)
|
|
if err != nil {
|
|
return addrs{}, err
|
|
}
|
|
return as, nil
|
|
}
|
|
|
|
func (e *extension) sendLoop(ctx context.Context) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case as := <-e.conn25.client.addrsCh:
|
|
if err := e.sendAddressAssignment(ctx, as); err != nil {
|
|
e.conn25.client.logf("error sending transit IP assignment (app: %s, mip: %v, src: %v): %v", as.app, as.magic, as.dst, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *client) enqueueAddressAssignment(addrs addrs) error {
|
|
select {
|
|
// TODO(fran) investigate the value of waiting for multiple addresses and sending them
|
|
// in one ConnectorTransitIPRequest
|
|
case c.addrsCh <- addrs:
|
|
return nil
|
|
default:
|
|
c.logf("address assignment queue full, dropping transit assignment for %v", addrs.domain)
|
|
return errors.New("queue full")
|
|
}
|
|
}
|
|
|
|
func makePeerAPIReq(ctx context.Context, httpClient *http.Client, urlBase string, as addrs) error {
|
|
url := urlBase + "/v0/connector/transit-ip"
|
|
|
|
reqBody := ConnectorTransitIPRequest{
|
|
TransitIPs: []TransitIPRequest{{
|
|
TransitIP: as.transit,
|
|
DestinationIP: as.dst,
|
|
App: as.app,
|
|
}},
|
|
}
|
|
bs, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return fmt.Errorf("marshalling request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bs))
|
|
if err != nil {
|
|
return fmt.Errorf("creating request: %w", err)
|
|
}
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("sending request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("connector returned HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
var respBody ConnectorTransitIPResponse
|
|
err = jsonDecode(&respBody, resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("decoding response: %w", err)
|
|
}
|
|
|
|
if len(respBody.TransitIPs) > 0 && respBody.TransitIPs[0].Code != OK {
|
|
return fmt.Errorf("connector error: %s", respBody.TransitIPs[0].Message)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *extension) sendAddressAssignment(ctx context.Context, as addrs) error {
|
|
app, ok := e.conn25.client.config.appsByName[as.app]
|
|
if !ok {
|
|
e.conn25.client.logf("App not found for app: %s (domain: %s)", as.app, as.domain)
|
|
return errors.New("app not found")
|
|
}
|
|
|
|
nb := e.host.NodeBackend()
|
|
peers := appc.PickConnector(nb, app)
|
|
var urlBase string
|
|
for _, p := range peers {
|
|
urlBase = nb.PeerAPIBase(p)
|
|
if urlBase != "" {
|
|
break
|
|
}
|
|
}
|
|
if urlBase == "" {
|
|
return errors.New("no connector peer found to handle address assignment")
|
|
}
|
|
client := e.backend.Sys().Dialer.Get().PeerAPIHTTPClient()
|
|
return makePeerAPIReq(ctx, client, urlBase, as)
|
|
}
|
|
|
|
func (c *client) mapDNSResponse(buf []byte) []byte {
|
|
var p dnsmessage.Parser
|
|
if _, err := p.Start(buf); err != nil {
|
|
c.logf("error parsing dns response: %v", err)
|
|
return buf
|
|
}
|
|
if err := p.SkipAllQuestions(); err != nil {
|
|
c.logf("error parsing dns response: %v", err)
|
|
return buf
|
|
}
|
|
for {
|
|
h, err := p.AnswerHeader()
|
|
if err == dnsmessage.ErrSectionDone {
|
|
break
|
|
}
|
|
if err != nil {
|
|
c.logf("error parsing dns response: %v", err)
|
|
return buf
|
|
}
|
|
|
|
if h.Class != dnsmessage.ClassINET {
|
|
if err := p.SkipAnswer(); err != nil {
|
|
c.logf("error parsing dns response: %v", err)
|
|
return buf
|
|
}
|
|
continue
|
|
}
|
|
|
|
switch h.Type {
|
|
case dnsmessage.TypeA:
|
|
domain, err := dnsname.ToFQDN(h.Name.String())
|
|
if err != nil {
|
|
c.logf("bad dnsname: %v", err)
|
|
return buf
|
|
}
|
|
if !c.isConnectorDomain(domain) {
|
|
if err := p.SkipAnswer(); err != nil {
|
|
c.logf("error parsing dns response: %v", err)
|
|
return buf
|
|
}
|
|
continue
|
|
}
|
|
r, err := p.AResource()
|
|
if err != nil {
|
|
c.logf("error parsing dns response: %v", err)
|
|
return buf
|
|
}
|
|
addrs, err := c.reserveAddresses(domain, netip.AddrFrom4(r.A))
|
|
if err != nil {
|
|
c.logf("error assigning connector addresses: %v", err)
|
|
return buf
|
|
}
|
|
if !addrs.isValid() {
|
|
c.logf("assigned connector addresses unexpectedly empty: %v", err)
|
|
return buf
|
|
}
|
|
default:
|
|
if err := p.SkipAnswer(); err != nil {
|
|
c.logf("error parsing dns response: %v", err)
|
|
return buf
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
|
|
// TODO(fran) 2026-01-21 return a dns response with addresses
|
|
// swapped out for the magic IPs to make conn25 work.
|
|
return buf
|
|
}
|
|
|
|
type connector struct {
|
|
logf logger.Logf
|
|
|
|
mu sync.Mutex // protects the fields below
|
|
// transitIPs is a map of connector client peer NodeID -> client transitIPs that we update as connector client peers instruct us to, and then use to route traffic to its destination on behalf of connector clients.
|
|
transitIPs map[tailcfg.NodeID]map[netip.Addr]appAddr
|
|
config config
|
|
}
|
|
|
|
func (s *connector) reconfig(newCfg config) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.config = newCfg
|
|
return nil
|
|
}
|
|
|
|
type addrs struct {
|
|
dst netip.Addr
|
|
magic netip.Addr
|
|
transit netip.Addr
|
|
domain dnsname.FQDN
|
|
app string
|
|
}
|
|
|
|
func (c addrs) isValid() bool {
|
|
return c.dst.IsValid()
|
|
}
|
|
|
|
// domainDst is a key for looking up an existing address assignment by the
|
|
// DNS response domain and destination IP pair.
|
|
type domainDst struct {
|
|
domain dnsname.FQDN
|
|
dst netip.Addr
|
|
}
|
|
|
|
// addrAssignments is the collection of addrs assigned by this client
|
|
// supporting lookup by magicip or domain+dst
|
|
type addrAssignments struct {
|
|
byMagicIP map[netip.Addr]addrs
|
|
byDomainDst map[domainDst]addrs
|
|
}
|
|
|
|
func (a *addrAssignments) insert(as addrs) error {
|
|
// we likely will want to allow overwriting in the future when we
|
|
// have address expiry, but for now this should not happen
|
|
if _, ok := a.byMagicIP[as.magic]; ok {
|
|
return errors.New("byMagicIP key exists")
|
|
}
|
|
ddst := domainDst{domain: as.domain, dst: as.dst}
|
|
if _, ok := a.byDomainDst[ddst]; ok {
|
|
return errors.New("byDomainDst key exists")
|
|
}
|
|
mak.Set(&a.byMagicIP, as.magic, as)
|
|
mak.Set(&a.byDomainDst, ddst, as)
|
|
return nil
|
|
}
|
|
|
|
func (a *addrAssignments) lookupByDomainDst(domain dnsname.FQDN, dst netip.Addr) (addrs, bool) {
|
|
v, ok := a.byDomainDst[domainDst{domain: domain, dst: dst}]
|
|
return v, ok
|
|
}
|
|
|