From fea3ef1176bd2a4f7071b0f5cd2ca84fa4b7d4c2 Mon Sep 17 00:00:00 2001 From: Lincoln Stoll Date: Thu, 28 Mar 2024 23:57:23 +0100 Subject: [PATCH 1/9] Move config to file Going to get more complex with k8s config + OIDC, make it a file. --- config.go | 89 +++++++++++++++++++++++++++++++++++ main.go | 120 ++++++++++++++---------------------------------- tsproxy_test.go | 55 ---------------------- 3 files changed, 123 insertions(+), 141 deletions(-) create mode 100644 config.go diff --git a/config.go b/config.go new file mode 100644 index 0000000..e42d784 --- /dev/null +++ b/config.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "strings" + + "github.com/tailscale/hujson" +) + +// configFile represents the on-disk configuration for this tsproxy instance. +type config struct { + // StateDir is where app state is stored, this should be persisted between + // rounds. Defaults to the user cache dir. If the Kubernetes config is set, + // it overrides this. + StateDir string `json:"stateDir"` + // MetricsDiscovery port sets the port we should listen for internal items + // for, i.e metrics and discovery info + MetricsDiscoveryPort int `json:"port"` + // LogTailscale indicates that we should log tailscale output + LogTailscale bool `json:"logTailscale"` + Upstreams []upstream `json:"upstreams"` +} + +// ConfigUpstream represents the configuration for a single upstream for this +// tsproxy instance. +type upstream struct { + Name string `json:"name"` + // Backend is the URL to the backend that serves this upstream + Backend string `json:"backend"` + Prometheus bool `json:"prometheus"` + Funnel bool `json:"funnel"` + + backendURL *url.URL +} + +func parseAndValidateConfig(cfg []byte) (config, error) { + b := []byte(os.Expand(string(cfg), getenvWithDefault)) + var c config + b, err := hujson.Standardize(b) + if err != nil { + return c, fmt.Errorf("standardizing config: %w", err) + } + if err := json.Unmarshal(b, &c); err != nil { + return c, fmt.Errorf("unmarshaling config: %w", err) + } + + // defaults + if c.MetricsDiscoveryPort == 0 { + c.MetricsDiscoveryPort = 32019 + } + + // validation + var verr error + if len(c.Upstreams) == 0 { + verr = errors.Join(verr, errors.New("at least one upstream must be provided")) + } + for _, u := range c.Upstreams { + if u.Name == "" { + verr = errors.Join(verr, errors.New("upstreams must have a name")) + } + if u.Backend == "" { + verr = errors.Join(verr, fmt.Errorf("upstream %s must have a backend", u.Name)) + } else { + p, err := url.Parse(u.Backend) + if err != nil { + verr = errors.Join(verr, fmt.Errorf("upstream %s backend url %s failed parsing: %w", u.Name, u.Backend, err)) + } else { + u.backendURL = p + } + } + } + + return c, nil +} + +// getenvWithDefault maps FOO:-default to $FOO or default if $FOO is unset or +// null. +func getenvWithDefault(key string) string { + parts := strings.SplitN(key, ":-", 2) + val := os.Getenv(parts[0]) + if val == "" && len(parts) == 2 { + val = parts[1] + } + return val +} diff --git a/main.go b/main.go index d30d115..bd403b4 100644 --- a/main.go +++ b/main.go @@ -4,8 +4,6 @@ import ( "context" "crypto/tls" "encoding/json" - "errors" - "flag" "fmt" "log/slog" "net" @@ -16,7 +14,6 @@ import ( "path/filepath" "sort" "strconv" - "strings" "syscall" "github.com/oklog/run" @@ -59,60 +56,12 @@ var ( ) ) -type upstreamFlag []upstream - -func (f *upstreamFlag) String() string { - return fmt.Sprintf("%+v", *f) -} - -func (f *upstreamFlag) Set(val string) error { - up, err := parseUpstreamFlag(val) - if err != nil { - return err - } - *f = append(*f, up) - return nil -} - -type upstream struct { - name string - backend *url.URL - prometheus bool - funnel bool -} - type target struct { name string magicDNS string prometheus bool } -func parseUpstreamFlag(fval string) (upstream, error) { - k, v, ok := strings.Cut(fval, "=") - if !ok { - return upstream{}, errors.New("format: name=http://backend") - } - val := strings.Split(v, ";") - be, err := url.Parse(val[0]) - if err != nil { - return upstream{}, err - } - up := upstream{name: k, backend: be} - if len(val) > 1 { - for _, opt := range val[1:] { - switch opt { - case "prometheus": - up.prometheus = true - case "funnel": - up.funnel = true - default: - return upstream{}, fmt.Errorf("unsupported option: %v", opt) - } - } - } - return up, nil -} - func main() { if err := tsproxy(context.Background()); err != nil { fmt.Fprintf(os.Stderr, "tsproxy: %v\n", err) @@ -121,19 +70,22 @@ func main() { } func tsproxy(ctx context.Context) error { - var ( - state = flag.String("state", "", "Optional directory for storing Tailscale state.") - tslog = flag.Bool("tslog", false, "If true, log Tailscale output.") - port = flag.Int("port", 32019, "HTTP port for metrics and service discovery.") - ) - var upstreams upstreamFlag - flag.Var(&upstreams, "upstream", "Repeated for each upstream. Format: name=http://backend:8000") - flag.Parse() + if len(os.Args) != 2 { + return fmt.Errorf("usage: %s ", os.Args[0]) + } + cfgb, err := os.ReadFile(os.Args[1]) + if err != nil { + return fmt.Errorf("reading config file %s: %w", os.Args[1], err) + } + cfg, err := parseAndValidateConfig(cfgb) + if err != nil { + return fmt.Errorf("reading config file %s: %w", os.Args[1], err) + } - if len(upstreams) == 0 { + if len(cfg.Upstreams) == 0 { return fmt.Errorf("required flag missing: upstream") } - if *state == "" { + if cfg.StateDir == "" { v, err := os.UserCacheDir() if err != nil { return err @@ -142,7 +94,7 @@ func tsproxy(ctx context.Context) error { if err := os.MkdirAll(dir, 0o700); err != nil { return err } - state = &dir + cfg.StateDir = dir } logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{})) @@ -158,7 +110,7 @@ func tsproxy(ctx context.Context) error { } // service discovery targets (self + all upstreams) - targets := make([]target, len(upstreams)+1) + targets := make([]target, len(cfg.Upstreams)+1) var g run.Group ctx, cancel := context.WithCancel(ctx) @@ -166,13 +118,13 @@ func tsproxy(ctx context.Context) error { g.Add(run.SignalHandler(ctx, os.Interrupt, syscall.SIGTERM)) { - p := strconv.Itoa(*port) + p := strconv.Itoa(cfg.MetricsDiscoveryPort) var listeners []net.Listener for _, ip := range st.Self.TailscaleIPs { ln, err := net.Listen("tcp", net.JoinHostPort(ip.String(), p)) if err != nil { - return fmt.Errorf("listen on %s:%d: %w", ip, *port, err) + return fmt.Errorf("listen on %s:%d: %w", ip, cfg.MetricsDiscoveryPort, err) } listeners = append(listeners, ln) } @@ -205,20 +157,16 @@ func tsproxy(ctx context.Context) error { } } - for i, upstream := range upstreams { - // https://go.dev/doc/faq#closures_and_goroutines - i := i - upstream := upstream - - log := logger.With(slog.String("upstream", upstream.name)) + for i, upstream := range cfg.Upstreams { + log := logger.With(slog.String("upstream", upstream.Name)) ts := &tsnet.Server{ - Hostname: upstream.name, - Dir: filepath.Join(*state, "tailscale-"+upstream.name), + Hostname: upstream.Name, + Dir: filepath.Join(cfg.StateDir, "tailscale-"+upstream.Name), } defer ts.Close() - if *tslog { + if cfg.LogTailscale { ts.Logf = func(format string, args ...any) { log.Info(fmt.Sprintf(format, args...), slog.String("logger", "tailscale")) } @@ -231,29 +179,29 @@ func tsproxy(ctx context.Context) error { lc, err := ts.LocalClient() if err != nil { - return fmt.Errorf("tailscale: get local client for %s: %w", upstream.name, err) + return fmt.Errorf("tailscale: get local client for %s: %w", upstream.Name, err) } srv := &http.Server{ TLSConfig: &tls.Config{GetCertificate: lc.GetCertificate}, - Handler: promhttp.InstrumentHandlerInFlight(requestsInFlight.With(prometheus.Labels{"upstream": upstream.name}), - promhttp.InstrumentHandlerDuration(duration.MustCurryWith(prometheus.Labels{"upstream": upstream.name}), - promhttp.InstrumentHandlerCounter(requests.MustCurryWith(prometheus.Labels{"upstream": upstream.name}), - newReverseProxy(log, lc, upstream.backend)))), + Handler: promhttp.InstrumentHandlerInFlight(requestsInFlight.With(prometheus.Labels{"upstream": upstream.Name}), + promhttp.InstrumentHandlerDuration(duration.MustCurryWith(prometheus.Labels{"upstream": upstream.Name}), + promhttp.InstrumentHandlerCounter(requests.MustCurryWith(prometheus.Labels{"upstream": upstream.Name}), + newReverseProxy(log, lc, upstream.backendURL)))), } g.Add(func() error { st, err := ts.Up(ctx) if err != nil { - return fmt.Errorf("tailscale: wait for node %s to be ready: %w", upstream.name, err) + return fmt.Errorf("tailscale: wait for node %s to be ready: %w", upstream.Name, err) } // register in service discovery when we're ready. - targets[i] = target{name: upstream.name, prometheus: upstream.prometheus, magicDNS: st.Self.DNSName} + targets[i] = target{name: upstream.Name, prometheus: upstream.Prometheus, magicDNS: st.Self.DNSName} ln, err := ts.Listen("tcp", ":80") if err != nil { - return fmt.Errorf("tailscale: listen for %s on port 80: %w", upstream.name, err) + return fmt.Errorf("tailscale: listen for %s on port 80: %w", upstream.Name, err) } return srv.Serve(ln) }, func(err error) { @@ -265,20 +213,20 @@ func tsproxy(ctx context.Context) error { g.Add(func() error { _, err := ts.Up(ctx) if err != nil { - return fmt.Errorf("tailscale: wait for node %s to be ready: %w", upstream.name, err) + return fmt.Errorf("tailscale: wait for node %s to be ready: %w", upstream.Name, err) } - if upstream.funnel { + if upstream.Funnel { ln, err := ts.ListenFunnel("tcp", ":443") if err != nil { - return fmt.Errorf("tailscale: funnel for %s on port 443: %w", upstream.name, err) + return fmt.Errorf("tailscale: funnel for %s on port 443: %w", upstream.Name, err) } return srv.Serve(ln) } ln, err := ts.Listen("tcp", ":443") if err != nil { - return fmt.Errorf("tailscale: listen for %s on port 443: %w", upstream.name, err) + return fmt.Errorf("tailscale: listen for %s on port 443: %w", upstream.Name, err) } return srv.ServeTLS(ln, "", "") }, func(err error) { diff --git a/tsproxy_test.go b/tsproxy_test.go index 4307237..fec3260 100644 --- a/tsproxy_test.go +++ b/tsproxy_test.go @@ -10,8 +10,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "reflect" - "strings" "testing" "github.com/google/go-cmp/cmp" @@ -29,59 +27,6 @@ func (c *fakeLocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apityp return c.whois(ctx, remoteAddr) } -func TestParseUpstream(t *testing.T) { - t.Parallel() - - for _, tc := range []struct { - upstream string - want upstream - err error - }{ - { - upstream: "test=http://example.com:-80/", - want: upstream{}, - err: errors.New(`parse "http://`), - }, - { - upstream: "test=http://localhost", - want: upstream{name: "test", backend: mustParseURL("http://localhost")}, - }, - { - upstream: "test=http://localhost;prometheus", - want: upstream{name: "test", backend: mustParseURL("http://localhost"), prometheus: true}, - }, - { - upstream: "test=http://localhost;funnel;prometheus", - want: upstream{name: "test", backend: mustParseURL("http://localhost"), prometheus: true, funnel: true}, - }, - { - upstream: "test=http://localhost;foo", - want: upstream{}, - err: errors.New("unsupported option: foo"), - }, - } { - tc := tc - t.Run(tc.upstream, func(t *testing.T) { - t.Parallel() - up, err := parseUpstreamFlag(tc.upstream) - if tc.err != nil { - if err == nil { - t.Fatalf("want err %v, got nil", tc.err) - } - if !strings.Contains(err.Error(), tc.err.Error()) { - t.Fatalf("want err %v, got %v", tc.err, err) - } - } - if tc.err == nil && err != nil { - t.Fatalf("want no err, got %v", err) - } - if diff := cmp.Diff(tc.want, up, cmp.Exporter(func(_ reflect.Type) bool { return true })); diff != "" { - t.Errorf("mismatch (-want +got):\n%s", diff) - } - }) - } -} - func mustParseURL(s string) *url.URL { v, err := url.Parse(s) if err != nil { From 2cd2edfbe8176f6efe90f5591aaf012d15d433be Mon Sep 17 00:00:00 2001 From: Lincoln Stoll Date: Fri, 29 Mar 2024 01:16:25 +0100 Subject: [PATCH 2/9] Kubernetes state backend Want to run it in k8s, add a backend that uses a secret to store the data. While tailscale has one in their codebase, it's not suitable for our usage so we just add a simple one. --- .gitignore | 1 + config.go | 48 ++++++++++++++++-- go.mod | 37 +++++++++++++- go.sum | 127 +++++++++++++++++++++++++++++++++++++++++++++- k8s_state.go | 117 ++++++++++++++++++++++++++++++++++++++++++ k8s_state_test.go | 43 ++++++++++++++++ main.go | 80 +++++++++++++++++++++++------ 7 files changed, 429 insertions(+), 24 deletions(-) create mode 100644 .gitignore create mode 100644 k8s_state.go create mode 100644 k8s_state_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d344ba6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.json diff --git a/config.go b/config.go index e42d784..23fad70 100644 --- a/config.go +++ b/config.go @@ -4,8 +4,10 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/url" "os" + "path/filepath" "strings" "github.com/tailscale/hujson" @@ -17,6 +19,9 @@ type config struct { // rounds. Defaults to the user cache dir. If the Kubernetes config is set, // it overrides this. StateDir string `json:"stateDir"` + // Kubernetes configures the proxy to run in a kubernetes cluster. In this + // case the StateDir is ignored, and state managed in a secret. + Kubernetes kubernetesConfig `json:"kubernetes"` // MetricsDiscovery port sets the port we should listen for internal items // for, i.e metrics and discovery info MetricsDiscoveryPort int `json:"port"` @@ -28,13 +33,25 @@ type config struct { // ConfigUpstream represents the configuration for a single upstream for this // tsproxy instance. type upstream struct { + // Name for this upstream. This is what it will be registered in tailscale + // as. Name string `json:"name"` // Backend is the URL to the backend that serves this upstream Backend string `json:"backend"` Prometheus bool `json:"prometheus"` Funnel bool `json:"funnel"` +} - backendURL *url.URL +type kubernetesConfig struct { + // Enabled enables the use of kubernetes for this proxy + Enabled bool `json:"enabled"` + // KubeconfigPath sets the path to the config to connect to the cluster. If + // not set, an in cluster config is used. + KubeconfigPath string `json:"kubeconfig"` + // Namespace to store the configmap in + Namespace string `json:"namespace"` + // Secret is the name of the secret, unique to this proxy instance + Secret string `json:"secret"` } func parseAndValidateConfig(cfg []byte) (config, error) { @@ -65,14 +82,37 @@ func parseAndValidateConfig(cfg []byte) (config, error) { if u.Backend == "" { verr = errors.Join(verr, fmt.Errorf("upstream %s must have a backend", u.Name)) } else { - p, err := url.Parse(u.Backend) + _, err := url.Parse(u.Backend) if err != nil { verr = errors.Join(verr, fmt.Errorf("upstream %s backend url %s failed parsing: %w", u.Name, u.Backend, err)) - } else { - u.backendURL = p } } } + if c.Kubernetes.Enabled { + if c.Kubernetes.Namespace == "" { + verr = errors.Join(verr, fmt.Errorf("namespace must be set when kubernetes is enabled")) + } + if c.Kubernetes.Secret == "" { + verr = errors.Join(verr, fmt.Errorf("secret must be set when kubernetes is enabled")) + } + } else { + if c.StateDir == "" { + v, err := os.UserCacheDir() + if err != nil { + return c, fmt.Errorf("finding user cache dir: %w", err) + } + dir := filepath.Join(v, "tsproxy") + log.Printf("dir: %s", dir) + if err := os.MkdirAll(dir, 0o700); err != nil { + return c, fmt.Errorf("creating %s: %w", dir, err) + } + c.StateDir = dir + } + } + + if verr != nil { + return c, fmt.Errorf("validating config failed: %w", err) + } return c, nil } diff --git a/go.mod b/go.mod index 0667e75..3fa18a4 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,10 @@ require ( github.com/google/go-cmp v0.6.0 github.com/oklog/run v1.1.0 github.com/prometheus/client_golang v1.18.0 + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a + k8s.io/api v0.29.3 + k8s.io/apimachinery v0.29.3 + k8s.io/client-go v0.29.3 tailscale.com v1.62.0 ) @@ -34,39 +38,57 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect + github.com/emicklei/go-restful/v3 v3.11.2 // indirect + github.com/evanphx/json-patch v5.6.0+incompatible // indirect github.com/fxamacker/cbor/v2 v2.5.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.20.2 // indirect + github.com/go-openapi/jsonreference v0.20.4 // indirect + github.com/go-openapi/swag v0.22.7 // indirect github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.2 // indirect + github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect + github.com/google/gofuzz v1.2.0 // indirect github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c // indirect github.com/google/uuid v1.5.0 // indirect github.com/gorilla/csrf v1.7.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/illarion/gonotify v1.0.1 // indirect + github.com/imdario/mergo v0.3.16 // indirect github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.4 // indirect github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/sdnotify v1.0.0 // indirect github.com/mdlayher/socket v0.5.0 // indirect github.com/miekg/dns v1.1.58 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.46.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/safchain/ethtool v0.3.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780 // indirect github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect - github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 // indirect @@ -82,6 +104,7 @@ require ( golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.20.0 // indirect + golang.org/x/oauth2 v0.16.0 // indirect golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.17.0 // indirect golang.org/x/term v0.16.0 // indirect @@ -90,7 +113,17 @@ require ( golang.org/x/tools v0.17.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect - google.golang.org/protobuf v1.32.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3 // indirect + k8s.io/klog/v2 v2.120.1 // indirect + k8s.io/kube-openapi v0.0.0-20240117194847-208609032b15 // indirect + k8s.io/utils v0.0.0-20240102154912-e7106e64919e // indirect nhooyr.io/websocket v1.8.10 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index 6041b9e..08bce20 100644 --- a/go.sum +++ b/go.sum @@ -61,27 +61,54 @@ github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI= github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40= +github.com/emicklei/go-restful/v3 v3.11.2 h1:1onLa9DcsMYO9P+CXaL0dStDqQ2EHHXLiz+BtnqkLAU= +github.com/emicklei/go-restful/v3 v3.11.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= +github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU= +github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4= +github.com/go-openapi/swag v0.22.7 h1:JWrc1uc/P9cSomxfnsFSVWoE1FW6bNbrVPmpQYpCcR8= +github.com/go-openapi/swag v0.22.7/go.mod h1:Gl91UqO+btAM0plGGxHqJcQZ1ZTy6jbmridBTsDy8A0= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 h1:0VpGH+cDhbDtdcweoyCVsF3fhN8kejK6rFe/2FFX2nU= +github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49/go.mod h1:BkkQ4L1KS1xMt2aWSPStnn55ChGC0DPOn2FQYj+f25M= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c h1:06RMfw+TMMHtRuUOroMeatRCCgSMWXCJQeABvHU69YQ= github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c/go.mod h1:BVIYo3cdnT4qSylnYqcd5YtmXhr51cJPGtnLBe/uLBU= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= @@ -92,6 +119,8 @@ github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio= github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= @@ -100,11 +129,17 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= @@ -115,6 +150,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= @@ -127,13 +164,26 @@ github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= +github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= +github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= +github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -150,7 +200,10 @@ github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDN github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= @@ -190,10 +243,17 @@ github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1Y github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8= go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= @@ -202,37 +262,82 @@ golang.org/x/exp/typeparams v0.0.0-20240119083558-1b970713d09a h1:8qmSSA8Gz/1kTr golang.org/x/exp/typeparams v0.0.0-20240119083558-1b970713d09a/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= +golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= -google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= -google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= @@ -244,8 +349,26 @@ honnef.co/go/tools v0.4.6 h1:oFEHCKeID7to/3autwsWfnuv69j3NsfcXbvJKuIcep8= honnef.co/go/tools v0.4.6/go.mod h1:+rnGS1THNh8zMwnd2oVOTL9QF6vmfyG6ZXBULae2uc0= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw= +k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80= +k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU= +k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= +k8s.io/client-go v0.29.3 h1:R/zaZbEAxqComZ9FHeQwOh3Y1ZUs7FaHKZdQtIc2WZg= +k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240117194847-208609032b15 h1:m6dl1pkxz3HuE2mP9MUYPCCGyy6IIFlv/vTlLBDxIwA= +k8s.io/kube-openapi v0.0.0-20240117194847-208609032b15/go.mod h1:Pa1PvrP7ACSkuX6I7KYomY6cmMA0Tx86waBhDUgoKPw= +k8s.io/utils v0.0.0-20240102154912-e7106e64919e h1:eQ/4ljkx21sObifjzXwlPKpdGLrCfRziVtos3ofG/sQ= +k8s.io/utils v0.0.0-20240102154912-e7106e64919e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q= nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= tailscale.com v1.62.0 h1:iI1fPDNXXETMbVEatos7xSR6Bv6aCuonD7B1X3glnPE= diff --git a/k8s_state.go b/k8s_state.go new file mode 100644 index 0000000..1e6d1d9 --- /dev/null +++ b/k8s_state.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "encoding/base32" + "fmt" + "sync" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/util/retry" + "tailscale.com/ipn" +) + +var _ ipn.StateStore = (*k8sStateStore)(nil) + +// k8sStateStore is an implementation of the tailscale state store that uses +// a secret to persist the data. It is not safe to share a secret across proxy +// instances. +type k8sStateStore struct { + clientset kubernetes.Interface + // namespace to keep the secret in + namespace string + // secret will be used as the name for the secret. this can serve + // multiple store instances, keys inside will be prefixed with name + secret string + // name of the service this state store is for. + name string + + currentSecret *corev1.Secret + storeMu sync.RWMutex +} + +func (k *k8sStateStore) ReadState(id ipn.StateKey) ([]byte, error) { + k.storeMu.RLock() + defer k.storeMu.RUnlock() + + ctx := context.Background() + + if k.currentSecret == nil { + sec, err := k.clientset.CoreV1().Secrets(k.namespace).Get(ctx, k.secret, metav1.GetOptions{}) + if err != nil { + if !apierrors.IsNotFound(err) { + // unexpected + return nil, fmt.Errorf("fetching %s/%s from destination: %v", k.namespace, k.name, err) + } + return nil, ipn.ErrStateNotExist + } + k.currentSecret = sec + } + + v, ok := k.currentSecret.Data[k.cmKeyForStateKey(id)] + if !ok { + return nil, ipn.ErrStateNotExist + } + + return v, nil +} + +// Put stores the data in the cache under the specified key. +// Underlying implementations may use any data storage format, +// as long as the reverse operation, Get, results in the original data. +func (k *k8sStateStore) WriteState(id ipn.StateKey, bs []byte) error { + k.storeMu.Lock() + defer k.storeMu.Unlock() + + ctx := context.Background() + + err := retry.RetryOnConflict(retry.DefaultBackoff, func() error { + var needsCreate bool + sec, err := k.clientset.CoreV1().Secrets(k.namespace).Get(ctx, k.secret, metav1.GetOptions{}) + if err != nil { + if !apierrors.IsNotFound(err) { + // unexpected + return fmt.Errorf("fetching %s/%s from destination: %v", k.namespace, k.name, err) + } + // item wasn't found, start with a new one + needsCreate = true + sec = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: k.namespace, + Name: k.secret, + }, + Data: map[string][]byte{}, + } + } + + sec.Data[k.cmKeyForStateKey(id)] = bs + + if needsCreate { + // need to return the raw error so the retry can detect a conflict and correctly retry. + // TODO at some point I hope error wrapping is supported, if it is return more descriptive with %w + if _, err := k.clientset.CoreV1().Secrets(k.namespace).Create(context.TODO(), sec, metav1.CreateOptions{}); err != nil { + return err + } + } else { + if _, err := k.clientset.CoreV1().Secrets(k.namespace).Update(context.TODO(), sec, metav1.UpdateOptions{}); err != nil { + return err + } + } + + k.currentSecret = sec + + return nil + }) + if err != nil { + return fmt.Errorf("putting in secret %s/%s: %v", k.namespace, k.secret, err) + } + + return nil +} + +func (c *k8sStateStore) cmKeyForStateKey(id ipn.StateKey) string { + return fmt.Sprintf("%s-%s", c.name, base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(id))) +} diff --git a/k8s_state_test.go b/k8s_state_test.go new file mode 100644 index 0000000..28c3e6f --- /dev/null +++ b/k8s_state_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "bytes" + "testing" + + "k8s.io/client-go/kubernetes/fake" + "tailscale.com/ipn" +) + +func TestCK8sState(t *testing.T) { + store := &k8sStateStore{ + clientset: fake.NewSimpleClientset(), + namespace: "test", + secret: "map", + name: "hostname", + } + + testData := []byte("blahblah") + + sk := ipn.StateKey("test") + + if _, err := store.ReadState(sk); err != ipn.ErrStateNotExist { + t.Errorf("wanted ipn.ErrStateNotExist, got: %v", err) + } + + if err := store.WriteState(sk, testData); err != nil { + t.Errorf("putting state: %v", err) + } + + got, err := store.ReadState(sk) + if err != nil { + t.Errorf("unexptected error getting cert: %v", err) + } + + if !bytes.Equal(testData, got) { + t.Errorf("wanted to get %s, but got: %v", string(testData), string(got)) + } + + if err := store.WriteState(sk, testData); err != nil { + t.Errorf("updating state: %v", err) + } +} diff --git a/main.go b/main.go index bd403b4..07164e0 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + "log" "log/slog" "net" "net/http" @@ -20,8 +21,13 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promhttp" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" "tailscale.com/client/tailscale" "tailscale.com/client/tailscale/apitype" + "tailscale.com/ipn" + "tailscale.com/ipn/store" "tailscale.com/tsnet" tslogger "tailscale.com/types/logger" ) @@ -85,17 +91,6 @@ func tsproxy(ctx context.Context) error { if len(cfg.Upstreams) == 0 { return fmt.Errorf("required flag missing: upstream") } - if cfg.StateDir == "" { - v, err := os.UserCacheDir() - if err != nil { - return err - } - dir := filepath.Join(v, "tsproxy") - if err := os.MkdirAll(dir, 0o700); err != nil { - return err - } - cfg.StateDir = dir - } logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{})) slog.SetDefault(logger) @@ -160,9 +155,19 @@ func tsproxy(ctx context.Context) error { for i, upstream := range cfg.Upstreams { log := logger.With(slog.String("upstream", upstream.Name)) + backendURL, err := url.Parse(upstream.Backend) + if err != nil { + return fmt.Errorf("parsing backend url %s: %w", backendURL, err) + } + + stateStore, err := stateStoreForUpstream(cfg, upstream.Name) + if err != nil { + return err + } + ts := &tsnet.Server{ Hostname: upstream.Name, - Dir: filepath.Join(cfg.StateDir, "tailscale-"+upstream.Name), + Store: stateStore, } defer ts.Close() @@ -173,21 +178,20 @@ func tsproxy(ctx context.Context) error { } else { ts.Logf = tslogger.Discard } - if err := os.MkdirAll(ts.Dir, 0o700); err != nil { - return err - } lc, err := ts.LocalClient() if err != nil { return fmt.Errorf("tailscale: get local client for %s: %w", upstream.Name, err) } + log.Info(fmt.Sprintf("backend %s upstream %#v", upstream.Name, backendURL)) + srv := &http.Server{ TLSConfig: &tls.Config{GetCertificate: lc.GetCertificate}, Handler: promhttp.InstrumentHandlerInFlight(requestsInFlight.With(prometheus.Labels{"upstream": upstream.Name}), promhttp.InstrumentHandlerDuration(duration.MustCurryWith(prometheus.Labels{"upstream": upstream.Name}), promhttp.InstrumentHandlerCounter(requests.MustCurryWith(prometheus.Labels{"upstream": upstream.Name}), - newReverseProxy(log, lc, upstream.backendURL)))), + newReverseProxy(log, lc, backendURL)))), } g.Add(func() error { @@ -322,3 +326,47 @@ func serveDiscovery(self string, targets []target) http.Handler { func lerr(err error) slog.Attr { return slog.String("err", err.Error()) } + +var clientset kubernetes.Interface + +func stateStoreForUpstream(cfg config, upstreamName string) (ipn.StateStore, error) { + if cfg.Kubernetes.Enabled { + if clientset == nil { + var kubeConfig *rest.Config + if cfg.Kubernetes.KubeconfigPath != "" { + c, err := clientcmd.BuildConfigFromFlags("", cfg.Kubernetes.KubeconfigPath) + if err != nil { + return nil, fmt.Errorf("building kubeconfig from %s: %w", cfg.Kubernetes.KubeconfigPath, err) + } + kubeConfig = c + } else { + c, err := rest.InClusterConfig() + if err != nil { + return nil, fmt.Errorf("building in-cluster kubeconfig: %w", err) + } + kubeConfig = c + } + cs, err := kubernetes.NewForConfig(kubeConfig) + if err != nil { + return nil, fmt.Errorf("building kubernetes clientset: %w", err) + } + clientset = cs + } + return &k8sStateStore{ + clientset: clientset, + namespace: cfg.Kubernetes.Namespace, + secret: cfg.Kubernetes.Secret, + name: upstreamName, + }, nil + } else { + dir := filepath.Join(cfg.StateDir, "tailscale-"+upstreamName) + if err := os.MkdirAll(cfg.StateDir, 0o700); err != nil { + return nil, fmt.Errorf("creating %s: %w", dir, err) + } + st, err := store.NewFileStore(log.Printf, dir) + if err != nil { + return nil, fmt.Errorf("creating file store at %s: %w", dir, err) + } + return st, nil + } +} From 0a06b11b8f0092dbbec3afd3b8c2bda0a4df4c2b Mon Sep 17 00:00:00 2001 From: Lincoln Stoll Date: Fri, 29 Mar 2024 12:01:40 +0100 Subject: [PATCH 3/9] use the hujson power --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d344ba6..e289a13 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -config.json +config.hujson From a54d002e1e18c88612d4f0ca078078de7208c275 Mon Sep 17 00:00:00 2001 From: Lincoln Stoll Date: Fri, 29 Mar 2024 14:59:58 +0100 Subject: [PATCH 4/9] Break out funnel listener Want a dedicated listener, so we can selectively apply auth etc. So just add a new listener/server for that. Redirect to https for internal requests on funnel fqdn's, to keep URLs consistent. Rework startup/shutdown a little to be more graceful. --- main.go | 142 ++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 112 insertions(+), 30 deletions(-) diff --git a/main.go b/main.go index 07164e0..d6d8a83 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,9 @@ import ( "path/filepath" "sort" "strconv" + "strings" "syscall" + "time" "github.com/oklog/run" "github.com/prometheus/client_golang/prometheus" @@ -186,20 +188,47 @@ func tsproxy(ctx context.Context) error { log.Info(fmt.Sprintf("backend %s upstream %#v", upstream.Name, backendURL)) - srv := &http.Server{ - TLSConfig: &tls.Config{GetCertificate: lc.GetCertificate}, - Handler: promhttp.InstrumentHandlerInFlight(requestsInFlight.With(prometheus.Labels{"upstream": upstream.Name}), - promhttp.InstrumentHandlerDuration(duration.MustCurryWith(prometheus.Labels{"upstream": upstream.Name}), - promhttp.InstrumentHandlerCounter(requests.MustCurryWith(prometheus.Labels{"upstream": upstream.Name}), - newReverseProxy(log, lc, backendURL)))), + // newServers constructs a http.Server with the base middleware/config + // in place. + newServer := func(h http.Handler) *http.Server { + return &http.Server{ + TLSConfig: &tls.Config{GetCertificate: lc.GetCertificate}, + Handler: promhttp.InstrumentHandlerInFlight(requestsInFlight.With(prometheus.Labels{"upstream": upstream.Name}), + promhttp.InstrumentHandlerDuration(duration.MustCurryWith(prometheus.Labels{"upstream": upstream.Name}), + promhttp.InstrumentHandlerCounter(requests.MustCurryWith(prometheus.Labels{"upstream": upstream.Name}), + h))), + } + } + + httpInterruptFunc := func(ctx context.Context, cancel func(), svr **http.Server) func(error) { + return func(error) { + if (*svr) != nil { + shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 5*time.Second) + defer shutdownCancel() + if err := (*svr).Shutdown(shutdownCtx); err != nil { + log.Error("server shutdown", lerr(err)) + } + } + cancel() + } } + var ( + httpServer *http.Server + httpCtx, httpCancel = context.WithCancel(ctx) + ) + g.Add(func() error { - st, err := ts.Up(ctx) + st, err := ts.Up(httpCtx) if err != nil { return fmt.Errorf("tailscale: wait for node %s to be ready: %w", upstream.Name, err) } + go func() { + <-httpCtx.Done() + log.Info("http context done") + }() + // register in service discovery when we're ready. targets[i] = target{name: upstream.Name, prometheus: upstream.Prometheus, magicDNS: st.Self.DNSName} @@ -207,41 +236,94 @@ func tsproxy(ctx context.Context) error { if err != nil { return fmt.Errorf("tailscale: listen for %s on port 80: %w", upstream.Name, err) } - return srv.Serve(ln) - }, func(err error) { - if err := srv.Close(); err != nil { - log.Error("server shutdown", lerr(err)) - } - cancel() - }) + + rp := newReverseProxy(log, lc, backendURL) + + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !upstream.Funnel { + rp.ServeHTTP(w, r) + return + } + + // if here, we have a funnel service too. To keep urls + // consistent between funnel and non-funnel access, force TLS. + // We need the cert anyway for funnel. If accessed by the short + // hostname though, just allow that through as that'd never map + // to the funnel side. + if len(strings.Split(r.Host, ".")) == 1 || r.URL.Scheme == "https" { + rp.ServeHTTP(w, r) + return + } + + http.Redirect(w, r, fmt.Sprintf("https://%s%s", strings.TrimSuffix(st.Self.DNSName, "."), r.RequestURI), http.StatusMovedPermanently) + }) + + httpServer = newServer(h) + + return httpServer.Serve(ln) + }, httpInterruptFunc(httpCtx, httpCancel, &httpServer)) + + var ( + httpsServer *http.Server + httpsCtx, httpsCancel = context.WithCancel(ctx) + ) + g.Add(func() error { - _, err := ts.Up(ctx) + _, err := ts.Up(httpsCtx) if err != nil { return fmt.Errorf("tailscale: wait for node %s to be ready: %w", upstream.Name, err) } - if upstream.Funnel { - ln, err := ts.ListenFunnel("tcp", ":443") - if err != nil { - return fmt.Errorf("tailscale: funnel for %s on port 443: %w", upstream.Name, err) - } - return srv.Serve(ln) - } + go func() { + <-httpsCtx.Done() + log.Info("https context done") + }() ln, err := ts.Listen("tcp", ":443") if err != nil { return fmt.Errorf("tailscale: listen for %s on port 443: %w", upstream.Name, err) } - return srv.ServeTLS(ln, "", "") - }, func(err error) { - if err := srv.Close(); err != nil { - log.Error("TLS server shutdown", lerr(err)) - } - cancel() - }) + + httpsServer = newServer(newReverseProxy(log, lc, backendURL)) + + return httpsServer.ServeTLS(ln, "", "") + }, httpInterruptFunc(httpsCtx, httpsCancel, &httpsServer)) + + if upstream.Funnel { + var ( + funnelServer *http.Server + funnelCtx, funnelCancel = context.WithCancel(ctx) + ) + + g.Add(func() error { + _, err := ts.Up(funnelCtx) + if err != nil { + return fmt.Errorf("tailscale: wait for node %s to be ready: %w", upstream.Name, err) + } + + go func() { + <-funnelCtx.Done() + log.Info("funnel context done") + }() + + ln, err := ts.ListenFunnel("tcp", ":443", tsnet.FunnelOnly()) + if err != nil { + return fmt.Errorf("tailscale: funnel for %s on port 443: %w", upstream.Name, err) + } + + rp := newReverseProxy(log, lc, backendURL) + + funnelServer = newServer(rp) + + return funnelServer.Serve(ln) + }, httpInterruptFunc(funnelCtx, funnelCancel, &funnelServer)) + } } - return g.Run() + if err := g.Run(); err != nil { + return fmt.Errorf("group run error: %w", err) + } + return nil } type tailscaleLocalClient interface { From 605d54988d526201b6cdb9355e0f0eec714d5ff7 Mon Sep 17 00:00:00 2001 From: Lincoln Stoll Date: Fri, 29 Mar 2024 16:29:04 +0100 Subject: [PATCH 5/9] OIDC auth for funnel Adds OIDC auth to funnel endpoints, setting user header consistently. Optionally paths can be specified to be authless. --- config.go | 23 +++++++++++++ cookie.go | 86 +++++++++++++++++++++++++++++++++++++++++++++++++ cookie_test.go | 60 ++++++++++++++++++++++++++++++++++ go.mod | 12 ++++--- go.sum | 24 ++++++++------ main.go | 71 ++++++++++++++++++++++++++++++++++------ tsproxy_test.go | 2 +- 7 files changed, 252 insertions(+), 26 deletions(-) create mode 100644 cookie.go create mode 100644 cookie_test.go diff --git a/config.go b/config.go index 23fad70..f19ea72 100644 --- a/config.go +++ b/config.go @@ -40,6 +40,20 @@ type upstream struct { Backend string `json:"backend"` Prometheus bool `json:"prometheus"` Funnel bool `json:"funnel"` + + // FunnelPublicPatterns sets the list of patterns where public (aka + // unauthenticated) access are allowed. If not set, no open access is + // permitted - and auth must be configured. The patterns are in + // http.ServeMux format. + FunnelPublicPatterns []string `json:"funnelPublicPatterns"` + + // OIDCIssuer sets the issuer to authenticate funnel access with. Any + // patterns not labeled as public will be handled by this. + OIDCIssuer string `json:"oidcIssuer"` + // OIDCClientID sets the OIDC client ID + OIDCClientID string `json:"oidcClientID"` + // OIDCClientSecret sets the OIDC client secret + OIDCClientSecret string `json:"oidcClientSecret"` } type kubernetesConfig struct { @@ -87,6 +101,15 @@ func parseAndValidateConfig(cfg []byte) (config, error) { verr = errors.Join(verr, fmt.Errorf("upstream %s backend url %s failed parsing: %w", u.Name, u.Backend, err)) } } + + if u.OIDCIssuer != "" { + if u.OIDCClientID == "" { + verr = errors.Join(verr, fmt.Errorf("upstream %s oidcClientID required", u.Name)) + } + if u.OIDCClientSecret == "" { + verr = errors.Join(verr, fmt.Errorf("upstream %s oidcClientSecret required", u.Name)) + } + } } if c.Kubernetes.Enabled { if c.Kubernetes.Namespace == "" { diff --git a/cookie.go b/cookie.go new file mode 100644 index 0000000..95d5409 --- /dev/null +++ b/cookie.go @@ -0,0 +1,86 @@ +package main + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + + "github.com/lstoll/oidc/middleware" +) + +const cookieName = "tsproxy-auth" + +var _ middleware.SessionStore + +type cookieAuthSession struct { +} + +func (cookieAuthSession) Get(r *http.Request) (*middleware.SessionData, error) { + c, err := r.Cookie(cookieName) + if err != nil { + if err == http.ErrNoCookie { + return &middleware.SessionData{}, nil + } + return nil, fmt.Errorf("fetching cookie %s: %w", cookieName, err) + } + + var sd middleware.SessionData + + b, err := base64.RawURLEncoding.DecodeString(c.Value) + if err != nil { + return nil, fmt.Errorf("base64 decode cookie: %w", err) + } + rdr, err := gzip.NewReader(bytes.NewReader(b)) + if err != nil { + return nil, fmt.Errorf("creating gzip reader: %w", err) + } + if err := json.NewDecoder(rdr).Decode(&sd); err != nil { + return nil, fmt.Errorf("decoding cookie: %w", err) + } + + return &sd, nil +} + +// Save should store the updated session. If the session data is nil, the +// session should be deleted. +func (cookieAuthSession) Save(w http.ResponseWriter, r *http.Request, sd *middleware.SessionData) error { + if sd == nil { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Secure: true, + HttpOnly: true, + Path: "/", + MaxAge: -1, + Value: "", + }) + return nil + } + + // unset access token as we don't use it, and refresh token because it's not + // safe to store unencrypted. + if sd.Token != nil { + sd.Token.AccessToken = "" + sd.Token.RefreshToken = "" + } + + buf := bytes.Buffer{} + gzw := gzip.NewWriter(&buf) + if err := json.NewEncoder(gzw).Encode(sd); err != nil { + return fmt.Errorf("zip/enc cookie: %w", err) + } + if err := gzw.Close(); err != nil { + return fmt.Errorf("closing gzip writer: %w", err) + } + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Secure: true, + HttpOnly: true, + Path: "/", + Value: base64.RawURLEncoding.EncodeToString(buf.Bytes()), + }) + + return nil +} diff --git a/cookie_test.go b/cookie_test.go new file mode 100644 index 0000000..f5b69c7 --- /dev/null +++ b/cookie_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/lstoll/oidc" + "github.com/lstoll/oidc/middleware" + "golang.org/x/oauth2" +) + +func TestCookieStore(t *testing.T) { + st := &cookieAuthSession{} + + r := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + sessData := &middleware.SessionData{ + State: "aaaaa", + Token: &oidc.MarshaledToken{ + Token: (&oauth2.Token{ + AccessToken: "access", + RefreshToken: "refresh", + }).WithExtra(map[string]any{ + "id_token": "idtoken", + }), + }, + } + + if err := st.Save(w, r, sessData); err != nil { + t.Fatal(err) + } + + r = httptest.NewRequest(http.MethodGet, "/", nil) + for _, c := range w.Result().Cookies() { + r.AddCookie(c) + } + + got, err := st.Get(r) + if err != nil { + t.Fatal(err) + } + + if got == nil { + t.Fatal("got nil response") + } + + if got.State != "aaaaa" { + t.Error("state missing") + } + + if got.Token.RefreshToken != "" { + t.Error("should be no refresh token") + } + + if idt, ok := got.Token.Extra("id_token").(string); !ok || idt != "idtoken" { + t.Errorf("response has no id token, ok: %t val: %s", ok, idt) + } +} diff --git a/go.mod b/go.mod index 3fa18a4..6ac06a3 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,11 @@ go 1.22.0 require ( github.com/google/go-cmp v0.6.0 + github.com/lstoll/oidc v1.0.0-alpha.2 github.com/oklog/run v1.1.0 github.com/prometheus/client_golang v1.18.0 github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a + golang.org/x/oauth2 v0.17.0 k8s.io/api v0.29.3 k8s.io/apimachinery v0.29.3 k8s.io/client-go v0.29.3 @@ -54,7 +56,7 @@ require ( github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c // indirect - github.com/google/uuid v1.5.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/csrf v1.7.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect @@ -94,20 +96,20 @@ require ( github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 // indirect github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272 // indirect github.com/tcnksm/go-httpstat v0.2.0 // indirect + github.com/tink-crypto/tink-go/v2 v2.1.0 // indirect github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e // indirect github.com/vishvananda/netlink v1.2.1-beta.2 // indirect github.com/vishvananda/netns v0.0.4 // indirect github.com/x448/float16 v0.8.4 // indirect go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.18.0 // indirect + golang.org/x/crypto v0.20.0 // indirect golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect golang.org/x/mod v0.14.0 // indirect - golang.org/x/net v0.20.0 // indirect - golang.org/x/oauth2 v0.16.0 // indirect + golang.org/x/net v0.21.0 // indirect golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.17.0 // indirect - golang.org/x/term v0.16.0 // indirect + golang.org/x/term v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.17.0 // indirect diff --git a/go.sum b/go.sum index 08bce20..f430428 100644 --- a/go.sum +++ b/go.sum @@ -109,8 +109,8 @@ github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c h1:06RMfw+TMMHtR github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c/go.mod h1:BVIYo3cdnT4qSylnYqcd5YtmXhr51cJPGtnLBe/uLBU= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= @@ -150,6 +150,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lstoll/oidc v1.0.0-alpha.2 h1:IWfXYeYY8rj2I8BeHdo23nacHKDPtK1OhBXeiBb2dDg= +github.com/lstoll/oidc v1.0.0-alpha.2/go.mod h1:ZQ/Awk92pRKZizlR6HD/TZpSLqvrMa/tlXDJod6mk0Q= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= @@ -232,6 +234,8 @@ github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0= github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8= +github.com/tink-crypto/tink-go/v2 v2.1.0 h1:QXFBguwMwTIaU17EgZpEJWsUSc60b1BAGTzBIoMdmok= +github.com/tink-crypto/tink-go/v2 v2.1.0/go.mod h1:y1TnYFt1i2eZVfx4OGc+C+EMp4CoKWAw2VSEuoicHHI= github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs= github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI= github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw= @@ -254,8 +258,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg= +golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= golang.org/x/exp/typeparams v0.0.0-20240119083558-1b970713d09a h1:8qmSSA8Gz/1kTrCe0nqR0R3Gb/NDhykzWw2q2mWZydM= @@ -273,10 +277,10 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= -golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= +golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -302,8 +306,8 @@ golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/main.go b/main.go index d6d8a83..1561289 100644 --- a/main.go +++ b/main.go @@ -13,12 +13,14 @@ import ( "net/url" "os" "path/filepath" + "slices" "sort" "strconv" "strings" "syscall" "time" + "github.com/lstoll/oidc/middleware" "github.com/oklog/run" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -237,7 +239,7 @@ func tsproxy(ctx context.Context) error { return fmt.Errorf("tailscale: listen for %s on port 80: %w", upstream.Name, err) } - rp := newReverseProxy(log, lc, backendURL) + rp := newReverseProxy(log, lc, backendURL, false) h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !upstream.Funnel { @@ -284,7 +286,7 @@ func tsproxy(ctx context.Context) error { return fmt.Errorf("tailscale: listen for %s on port 443: %w", upstream.Name, err) } - httpsServer = newServer(newReverseProxy(log, lc, backendURL)) + httpsServer = newServer(newReverseProxy(log, lc, backendURL, false)) return httpsServer.ServeTLS(ln, "", "") }, httpInterruptFunc(httpsCtx, httpsCancel, &httpsServer)) @@ -296,7 +298,7 @@ func tsproxy(ctx context.Context) error { ) g.Add(func() error { - _, err := ts.Up(funnelCtx) + st, err := ts.Up(funnelCtx) if err != nil { return fmt.Errorf("tailscale: wait for node %s to be ready: %w", upstream.Name, err) } @@ -311,9 +313,36 @@ func tsproxy(ctx context.Context) error { return fmt.Errorf("tailscale: funnel for %s on port 443: %w", upstream.Name, err) } - rp := newReverseProxy(log, lc, backendURL) + rp := newReverseProxy(log, lc, backendURL, true) - funnelServer = newServer(rp) + // TODO pass public paths direct to the proxy + mux := http.NewServeMux() + + for _, p := range upstream.FunnelPublicPatterns { + mux.Handle(p, rp) + } + + if upstream.OIDCIssuer != "" { + baseURL := "https://" + strings.TrimSuffix(st.Self.DNSName, ".") + + oidcm := &middleware.Handler{ + Issuer: upstream.OIDCIssuer, + ClientID: upstream.OIDCClientID, + ClientSecret: upstream.OIDCClientSecret, + BaseURL: baseURL, + RedirectURL: baseURL + "/.tsproxy/oidc-callback", + SessionStore: &cookieAuthSession{}, + AdditionalScopes: []string{"profile"}, // make sure we have email etc. + } + mux.Handle("/", oidcm.Wrap(rp)) // fallback to authed path. + } else if !slices.Contains(upstream.FunnelPublicPatterns, "/") { + // no OIDC auth, no root pattern, default behaviour is to block. + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + }) + } + + funnelServer = newServer(mux) return funnelServer.Serve(ln) }, httpInterruptFunc(funnelCtx, funnelCancel, &funnelServer)) @@ -330,7 +359,7 @@ type tailscaleLocalClient interface { WhoIs(context.Context, string) (*apitype.WhoIsResponse, error) } -func newReverseProxy(logger *slog.Logger, lc tailscaleLocalClient, url *url.URL) http.HandlerFunc { +func newReverseProxy(logger *slog.Logger, lc tailscaleLocalClient, url *url.URL, isFunnel bool) http.HandlerFunc { // TODO(sr) Instrument proxy.Transport rproxy := &httputil.ReverseProxy{ Rewrite: func(req *httputil.ProxyRequest) { @@ -345,6 +374,8 @@ func newReverseProxy(logger *slog.Logger, lc tailscaleLocalClient, url *url.URL) } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // lookup and check whois regardless, extra check to make sure traffic + // is coming over the valid tailscale net. whois, err := lc.WhoIs(r.Context(), r.RemoteAddr) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -364,15 +395,35 @@ func newReverseProxy(logger *slog.Logger, lc tailscaleLocalClient, url *url.URL) return } - // Proxy requests from tagged nodes as is. - if whois.Node.IsTagged() { + // Proxy requests from non-funnel tagged nodes as is. + // + // TODO(lstoll) figure out why - and if end-user nodes would be tagged? + if whois.Node.IsTagged() && !isFunnel { rproxy.ServeHTTP(w, r) return } + loginName := whois.UserProfile.LoginName + displayName := whois.UserProfile.DisplayName + + if isFunnel { + cl := middleware.ClaimsFromContext(r.Context()) + if cl != nil { + email := cl.Extra["email"].(string) + name := cl.Extra["name"].(string) + if email == "" || name == "" { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + logger.Error("oidc id token missing name or email", slog.String("email", email), slog.String("name", name)) + return + } + loginName = email + displayName = name + } + } + req := r.Clone(r.Context()) - req.Header.Set("X-Webauth-User", whois.UserProfile.LoginName) - req.Header.Set("X-Webauth-Name", whois.UserProfile.DisplayName) + req.Header.Set("X-Webauth-User", loginName) + req.Header.Set("X-Webauth-Name", displayName) rproxy.ServeHTTP(w, req) }) } diff --git a/tsproxy_test.go b/tsproxy_test.go index fec3260..d3b6cf9 100644 --- a/tsproxy_test.go +++ b/tsproxy_test.go @@ -99,7 +99,7 @@ func TestReverseProxy(t *testing.T) { if err != nil { log.Fatal(err) } - px := httptest.NewServer(newReverseProxy(slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})), lc, beURL)) + px := httptest.NewServer(newReverseProxy(slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})), lc, beURL, false)) defer px.Close() resp, err := http.Get(px.URL) From b86757cecd7f53b234a8669257935e986529d6b8 Mon Sep 17 00:00:00 2001 From: Lincoln Stoll Date: Fri, 29 Mar 2024 16:34:56 +0100 Subject: [PATCH 6/9] an example config --- config.hujson.example | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 config.hujson.example diff --git a/config.hujson.example b/config.hujson.example new file mode 100644 index 0000000..e10c26e --- /dev/null +++ b/config.hujson.example @@ -0,0 +1,22 @@ +{ + "logTailscale": true, + "kubernetes": { + "enabled": true, + "kubeconfig": "${HOME}/.kube/config", + "namespace": "default", + "secret": "tsproxy", + }, + "upstreams": [ + { + "name": "tsproxydev", + "backend": "http://localhost:8080", + "funnel": true, + "funnelPublicPatterns": [ + "/testPublic", + ], + "oidcIssuer": "https://oidc-issuer", + "oidcClientID": "tsproxydev", + "oidcClientSecret": "XXXX", + }, + ], +} From 2db2f476acb94df787c0a3ab722c73247ebacbca Mon Sep 17 00:00:00 2001 From: Lincoln Stoll Date: Fri, 29 Mar 2024 17:04:22 +0100 Subject: [PATCH 7/9] CI build + docker publish --- .github/workflows/go.yml | 81 ++++++++++++++++++++++++++++++++++++++++ Dockerfile | 11 ++++++ tsproxy_test.go | 8 ---- 3 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/go.yml create mode 100644 Dockerfile diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..aa9deee --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,81 @@ +name: Go +on: + push: + branches: + - '**' + tags: + - 'v*.*.*' +jobs: + + build: + name: Build + runs-on: ubuntu-latest + env: + CGO_ENABLED: 0 + steps: + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: stable + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + + - name: go mod download + run: | + go mod download + + - name: Cross-build + run: | + GOOS=linux GOARCH=amd64 go build -o tsproxy-amd64 . + GOOS=linux GOARCH=arm64 go build -o tsproxy-arm64 . + + - name: Test + run: | + go test ./... + + - name: Lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest + + - name: Docker meta + id: meta + uses: docker/metadata-action@v4 + with: + # list of Docker images to use as base name for tags + images: | + ghcr.io/sr/tsproxy + # generate Docker tags based on the following events/attributes + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d84df37 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM debian:bookworm +ARG TARGETARCH + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y ca-certificates + +COPY tsproxy-$TARGETARCH /usr/bin/tsproxy + +CMD ["/usr/bin/tsproxy"] diff --git a/tsproxy_test.go b/tsproxy_test.go index d3b6cf9..eafb269 100644 --- a/tsproxy_test.go +++ b/tsproxy_test.go @@ -27,14 +27,6 @@ func (c *fakeLocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apityp return c.whois(ctx, remoteAddr) } -func mustParseURL(s string) *url.URL { - v, err := url.Parse(s) - if err != nil { - panic(err) - } - return v -} - func TestReverseProxy(t *testing.T) { t.Parallel() From c95b8e8f93b35a627177ae04a607b2716d7e6c2d Mon Sep 17 00:00:00 2001 From: Lincoln Stoll Date: Fri, 29 Mar 2024 18:54:35 +0100 Subject: [PATCH 8/9] Disable local tailscale usage When running in a container, tailscale doesn't already exist so we can't talk to it. Just use a local listener, need to think about this more. --- main.go | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/main.go b/main.go index 1561289..1d285b7 100644 --- a/main.go +++ b/main.go @@ -28,7 +28,6 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" - "tailscale.com/client/tailscale" "tailscale.com/client/tailscale/apitype" "tailscale.com/ipn" "tailscale.com/ipn/store" @@ -100,13 +99,15 @@ func tsproxy(ctx context.Context) error { slog.SetDefault(logger) // If tailscaled isn't ready yet, just crash. - st, err := (&tailscale.LocalClient{}).Status(ctx) - if err != nil { - return fmt.Errorf("tailscale: get node status: %w", err) - } - if v := len(st.Self.TailscaleIPs); v != 2 { - return fmt.Errorf("want 2 tailscale IPs, got %d", v) - } + /* + st, err := (&tailscale.LocalClient{}).Status(ctx) + if err != nil { + return fmt.Errorf("tailscale: get node status: %w", err) + } + if v := len(st.Self.TailscaleIPs); v != 2 { + return fmt.Errorf("want 2 tailscale IPs, got %d", v) + } + */ // service discovery targets (self + all upstreams) targets := make([]target, len(cfg.Upstreams)+1) @@ -120,16 +121,22 @@ func tsproxy(ctx context.Context) error { p := strconv.Itoa(cfg.MetricsDiscoveryPort) var listeners []net.Listener - for _, ip := range st.Self.TailscaleIPs { - ln, err := net.Listen("tcp", net.JoinHostPort(ip.String(), p)) - if err != nil { - return fmt.Errorf("listen on %s:%d: %w", ip, cfg.MetricsDiscoveryPort, err) - } - listeners = append(listeners, ln) + /* + for _, ip := range st.Self.TailscaleIPs { + ln, err := net.Listen("tcp", net.JoinHostPort(ip.String(), p)) + if err != nil { + return fmt.Errorf("listen on %s:%d: %w", ip, cfg.MetricsDiscoveryPort, err) + } + listeners = append(listeners, ln) + }*/ + ln, err := net.Listen("tcp", "0.0.0.0:"+p) + if err != nil { + return fmt.Errorf("creating metrics listener: %w", err) } + listeners = append(listeners, ln) http.Handle("/metrics", promhttp.Handler()) - http.Handle("/sd", serveDiscovery(net.JoinHostPort(st.Self.DNSName, p), targets)) + //http.Handle("/sd", serveDiscovery(net.JoinHostPort(st.Self.DNSName, p), targets)) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(` tsproxy From 0e14a981dec8f766bbd13f8b4587d63661869d61 Mon Sep 17 00:00:00 2001 From: Lincoln Stoll Date: Fri, 29 Mar 2024 19:06:39 +0100 Subject: [PATCH 9/9] correct error message --- k8s_state.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s_state.go b/k8s_state.go index 1e6d1d9..3392925 100644 --- a/k8s_state.go +++ b/k8s_state.go @@ -44,7 +44,7 @@ func (k *k8sStateStore) ReadState(id ipn.StateKey) ([]byte, error) { if err != nil { if !apierrors.IsNotFound(err) { // unexpected - return nil, fmt.Errorf("fetching %s/%s from destination: %v", k.namespace, k.name, err) + return nil, fmt.Errorf("fetching %s/%s from destination: %v", k.namespace, k.secret, err) } return nil, ipn.ErrStateNotExist } @@ -74,7 +74,7 @@ func (k *k8sStateStore) WriteState(id ipn.StateKey, bs []byte) error { if err != nil { if !apierrors.IsNotFound(err) { // unexpected - return fmt.Errorf("fetching %s/%s from destination: %v", k.namespace, k.name, err) + return fmt.Errorf("fetching %s/%s from destination: %v", k.namespace, k.secret, err) } // item wasn't found, start with a new one needsCreate = true