Просмотр исходного кода

appctype: introduce a configuration schema for app connectors

Updates tailscale/corp#15043

Signed-off-by: James Tucker <[email protected]>
James Tucker 2 лет назад
Родитель
Сommit
ce0830837d
2 измененных файлов с 137 добавлено и 0 удалено
  1. 59 0
      appctype/appconnector.go
  2. 78 0
      appctype/appconnector_test.go

+ 59 - 0
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"`
+}

+ 78 - 0
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)
+	}
+}