diff --git a/appctype/appconnector.go b/appctype/appconnector.go new file mode 100644 index 000000000..178363008 --- /dev/null +++ b/appctype/appconnector.go @@ -0,0 +1,59 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package appcfg contains an experimental configuration structure for +// "tailscale.com/app-connector" capmap extensions. +package appctype + +import ( + "net/netip" + + "tailscale.com/tailcfg" +) + +// ConfigID is an opaque identifier for a configuration. +type ConfigID string + +// AppConnectorConfig is the configuration structure for an application +// connection proxy service. +type AppConnectorConfig struct { + // DNAT is a map of destination NAT configurations. + DNAT map[ConfigID]DNATConfig `json:",omitempty"` + // SNIProxy is a map of SNI proxy configurations. + SNIProxy map[ConfigID]SNIProxyConfig `json:",omitempty"` + + // AdvertiseRoutes indicates that the node should advertise routes for each + // of the addresses in service configuration address lists. If false, the + // routes have already been advertised. + AdvertiseRoutes bool `json:",omitempty"` +} + +// DNATConfig is the configuration structure for a destination NAT service, also +// known as a "port forward" or "port proxy". +type DNATConfig struct { + // Addrs is a list of addresses to listen on. + Addrs []netip.Addr `json:",omitempty"` + + // To is a list of destination addresses to forward traffic to. It should + // only contain one domain, or a list of IP addresses. + To []string `json:",omitempty"` + + // IP is a list of IP specifications to forward. If omitted, all protocols are + // forwarded. IP specifications are of the form "tcp/80", "udp/53", etc. + IP []tailcfg.ProtoPortRange `json:",omitempty"` +} + +// SNIPRoxyConfig is the configuration structure for an SNI proxy service, +// forwarding TLS connections based on the hostname field in SNI. +type SNIProxyConfig struct { + // Addrs is a list of addresses to listen on. + Addrs []netip.Addr `json:",omitempty"` + + // IP is a list of IP specifications to forward. If omitted, all protocols are + // forwarded. IP specifications are of the form "tcp/80", "udp/53", etc. + IP []tailcfg.ProtoPortRange `json:",omitempty"` + + // AllowedDomains is a list of domains that are allowed to be proxied. If + // the domain starts with a `.` that means any subdomain of the suffix. + AllowedDomains []string `json:",omitempty"` +} diff --git a/appctype/appconnector_test.go b/appctype/appconnector_test.go new file mode 100644 index 000000000..390d1776a --- /dev/null +++ b/appctype/appconnector_test.go @@ -0,0 +1,78 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package appctype + +import ( + "encoding/json" + "net/netip" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "tailscale.com/tailcfg" + "tailscale.com/util/must" +) + +var golden = `{ + "dnat": { + "opaqueid1": { + "addrs": ["100.64.0.1", "fd7a:115c:a1e0::1"], + "to": ["example.org"], + "ip": ["*"] + } + }, + "sniProxy": { + "opaqueid2": { + "addrs": ["::"], + "ip": ["tcp:443"], + "allowedDomains": ["*"] + } + }, + "advertiseRoutes": true +}` + +func TestGolden(t *testing.T) { + wantDNAT := map[ConfigID]DNATConfig{"opaqueid1": { + Addrs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}, + To: []string{"example.org"}, + IP: []tailcfg.ProtoPortRange{{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}}}, + }} + + wantSNI := map[ConfigID]SNIProxyConfig{"opaqueid2": { + Addrs: []netip.Addr{netip.MustParseAddr("::")}, + IP: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 443, Last: 443}}}, + AllowedDomains: []string{"*"}, + }} + + var config AppConnectorConfig + if err := json.NewDecoder(strings.NewReader(golden)).Decode(&config); err != nil { + t.Fatalf("failed to decode golden config: %v", err) + } + + if !config.AdvertiseRoutes { + t.Fatalf("expected AdvertiseRoutes to be true, got false") + } + + assertEqual(t, "DNAT", config.DNAT, wantDNAT) + assertEqual(t, "SNI", config.SNIProxy, wantSNI) +} + +func TestRoundTrip(t *testing.T) { + var config AppConnectorConfig + must.Do(json.NewDecoder(strings.NewReader(golden)).Decode(&config)) + b := must.Get(json.Marshal(config)) + var config2 AppConnectorConfig + must.Do(json.Unmarshal(b, &config2)) + assertEqual(t, "DNAT", config.DNAT, config2.DNAT) +} + +func assertEqual(t *testing.T, name string, a, b any) { + var addrComparer = cmp.Comparer(func(a, b netip.Addr) bool { + return a.Compare(b) == 0 + }) + t.Helper() + if diff := cmp.Diff(a, b, addrComparer); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } +}