// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

package ipn

import (
	"testing"

	"tailscale.com/ipn/ipnstate"
	"tailscale.com/tailcfg"
)

func TestCheckFunnelAccess(t *testing.T) {
	caps := func(c ...tailcfg.NodeCapability) []tailcfg.NodeCapability { return c }
	const portAttr tailcfg.NodeCapability = "https://tailscale.com/cap/funnel-ports?ports=443,8080-8090,8443,"
	tests := []struct {
		port    uint16
		caps    []tailcfg.NodeCapability
		wantErr bool
	}{
		{443, caps(portAttr), true}, // No "funnel" attribute
		{443, caps(portAttr, tailcfg.NodeAttrFunnel), true},
		{443, caps(portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel), false},
		{8443, caps(portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel), false},
		{8321, caps(portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel), true},
		{8083, caps(portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel), false},
		{8091, caps(portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel), true},
		{3000, caps(portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel), true},
	}
	for _, tt := range tests {
		cm := tailcfg.NodeCapMap{}
		for _, c := range tt.caps {
			cm[c] = nil
		}
		err := CheckFunnelAccess(tt.port, &ipnstate.PeerStatus{CapMap: cm})
		switch {
		case err != nil && tt.wantErr,
			err == nil && !tt.wantErr:
			continue
		case tt.wantErr:
			t.Fatalf("got no error, want error")
		case !tt.wantErr:
			t.Fatalf("got error %v, want no error", err)
		}
	}
}

func TestHasPathHandler(t *testing.T) {
	tests := []struct {
		name string
		cfg  ServeConfig
		want bool
	}{
		{
			name: "empty-config",
			cfg:  ServeConfig{},
			want: false,
		},
		{
			name: "with-bg-path-handler",
			cfg: ServeConfig{
				TCP: map[uint16]*TCPPortHandler{80: {HTTP: true}},
				Web: map[HostPort]*WebServerConfig{
					"foo.test.ts.net:80": {Handlers: map[string]*HTTPHandler{
						"/": {Path: "/tmp"},
					}},
				},
			},
			want: true,
		},
		{
			name: "with-fg-path-handler",
			cfg: ServeConfig{
				TCP: map[uint16]*TCPPortHandler{
					443: {HTTPS: true},
				},
				Foreground: map[string]*ServeConfig{
					"abc123": {
						TCP: map[uint16]*TCPPortHandler{80: {HTTP: true}},
						Web: map[HostPort]*WebServerConfig{
							"foo.test.ts.net:80": {Handlers: map[string]*HTTPHandler{
								"/": {Path: "/tmp"},
							}},
						},
					},
				},
			},
			want: true,
		},
		{
			name: "with-no-bg-path-handler",
			cfg: ServeConfig{
				TCP: map[uint16]*TCPPortHandler{443: {HTTPS: true}},
				Web: map[HostPort]*WebServerConfig{
					"foo.test.ts.net:443": {Handlers: map[string]*HTTPHandler{
						"/": {Proxy: "http://127.0.0.1:3000"},
					}},
				},
				AllowFunnel: map[HostPort]bool{"foo.test.ts.net:443": true},
			},
			want: false,
		},
		{
			name: "with-no-fg-path-handler",
			cfg: ServeConfig{
				Foreground: map[string]*ServeConfig{
					"abc123": {
						TCP: map[uint16]*TCPPortHandler{443: {HTTPS: true}},
						Web: map[HostPort]*WebServerConfig{
							"foo.test.ts.net:443": {Handlers: map[string]*HTTPHandler{
								"/": {Proxy: "http://127.0.0.1:3000"},
							}},
						},
						AllowFunnel: map[HostPort]bool{"foo.test.ts.net:443": true},
					},
				},
			},
			want: false,
		},
		{
			name: "with-service-path-handler",
			cfg: ServeConfig{
				Services: map[tailcfg.ServiceName]*ServiceConfig{
					"svc:foo": {
						Web: map[HostPort]*WebServerConfig{
							"foo.test.ts.net:443": {Handlers: map[string]*HTTPHandler{
								"/": {Path: "/tmp"},
							}},
						},
					},
				},
			},
			want: true,
		},
		{
			name: "with-service-proxy-handler",
			cfg: ServeConfig{
				Services: map[tailcfg.ServiceName]*ServiceConfig{
					"svc:foo": {
						Web: map[HostPort]*WebServerConfig{
							"foo.test.ts.net:443": {Handlers: map[string]*HTTPHandler{
								"/": {Proxy: "http://127.0.0.1:3000"},
							}},
						},
					},
				},
			},
			want: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := tt.cfg.HasPathHandler()
			if tt.want != got {
				t.Errorf("HasPathHandler() = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestIsTCPForwardingOnPort(t *testing.T) {
	tests := []struct {
		name    string
		cfg     ServeConfig
		svcName tailcfg.ServiceName
		port    uint16
		want    bool
	}{
		{
			name:    "empty-config",
			cfg:     ServeConfig{},
			svcName: "",
			port:    80,
			want:    false,
		},
		{
			name: "node-tcp-config-match",
			cfg: ServeConfig{
				TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
			},
			svcName: "",
			port:    80,
			want:    true,
		},
		{
			name: "node-tcp-config-no-match",
			cfg: ServeConfig{
				TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
			},
			svcName: "",
			port:    443,
			want:    false,
		},
		{
			name: "node-tcp-config-no-match-with-service",
			cfg: ServeConfig{
				TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
			},
			svcName: "svc:bar",
			port:    80,
			want:    false,
		},
		{
			name: "node-web-config-no-match",
			cfg: ServeConfig{
				TCP: map[uint16]*TCPPortHandler{80: {HTTPS: true}},
				Web: map[HostPort]*WebServerConfig{
					"foo.test.ts.net:80": {
						Handlers: map[string]*HTTPHandler{
							"/": {Text: "Hello, world!"},
						},
					},
				},
			},
			svcName: "",
			port:    80,
			want:    false,
		},
		{
			name: "service-tcp-config-match",
			cfg: ServeConfig{
				Services: map[tailcfg.ServiceName]*ServiceConfig{
					"svc:foo": {
						TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
					},
				},
			},
			svcName: "svc:foo",
			port:    80,
			want:    true,
		},
		{
			name: "service-tcp-config-no-match",
			cfg: ServeConfig{
				Services: map[tailcfg.ServiceName]*ServiceConfig{
					"svc:foo": {
						TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
					},
				},
			},
			svcName: "svc:bar",
			port:    80,
			want:    false,
		},
		{
			name: "service-web-config-no-match",
			cfg: ServeConfig{
				Services: map[tailcfg.ServiceName]*ServiceConfig{
					"svc:foo": {
						TCP: map[uint16]*TCPPortHandler{80: {HTTPS: true}},
						Web: map[HostPort]*WebServerConfig{
							"foo.test.ts.net:80": {
								Handlers: map[string]*HTTPHandler{
									"/": {Text: "Hello, world!"},
								},
							},
						},
					},
				},
			},
			svcName: "svc:foo",
			port:    80,
			want:    false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := tt.cfg.IsTCPForwardingOnPort(tt.port, tt.svcName)
			if tt.want != got {
				t.Errorf("IsTCPForwardingOnPort() = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestExpandProxyTargetDev(t *testing.T) {
	tests := []struct {
		name             string
		input            string
		defaultScheme    string
		supportedSchemes []string
		expected         string
		wantErr          bool
	}{
		{name: "port-only", input: "8080", expected: "http://127.0.0.1:8080"},
		{name: "hostname-and-port", input: "localhost:8080", expected: "http://localhost:8080"},
		{name: "no-change", input: "http://127.0.0.1:8080", expected: "http://127.0.0.1:8080"},
		{name: "include-path", input: "http://127.0.0.1:8080/foo", expected: "http://127.0.0.1:8080/foo"},
		{name: "https-scheme", input: "https://localhost:8080", expected: "https://localhost:8080"},
		{name: "https-insecure-scheme", input: "https+insecure://localhost:8080", expected: "https+insecure://localhost:8080"},
		{name: "change-default-scheme", input: "localhost:8080", defaultScheme: "https", expected: "https://localhost:8080"},
		{name: "change-supported-schemes", input: "localhost:8080", defaultScheme: "tcp", supportedSchemes: []string{"tcp"}, expected: "tcp://localhost:8080"},
		{name: "remote-target", input: "https://example.com:8080", expected: "https://example.com:8080"},
		{name: "remote-IP-target", input: "http://120.133.20.2:8080", expected: "http://120.133.20.2:8080"},
		{name: "remote-target-no-port", input: "https://example.com", expected: "https://example.com"},

		// errors
		{name: "invalid-port", input: "localhost:9999999", wantErr: true},
		{name: "invalid-hostname", input: "192.168.1:8080", wantErr: true},
		{name: "unsupported-scheme", input: "ftp://localhost:8080", expected: "", wantErr: true},
		{name: "empty-input", input: "", expected: "", wantErr: true},
		{name: "localhost-no-port", input: "localhost", expected: "", wantErr: true},
	}

	for _, tt := range tests {
		defaultScheme := "http"
		supportedSchemes := []string{"http", "https", "https+insecure"}

		if tt.supportedSchemes != nil {
			supportedSchemes = tt.supportedSchemes
		}
		if tt.defaultScheme != "" {
			defaultScheme = tt.defaultScheme
		}

		t.Run(tt.name, func(t *testing.T) {
			actual, err := ExpandProxyTargetValue(tt.input, supportedSchemes, defaultScheme)

			if tt.wantErr == true && err == nil {
				t.Errorf("Expected an error but got none")
				return
			}

			if tt.wantErr == false && err != nil {
				t.Errorf("Got an error, but didn't expect one: %v", err)
				return
			}

			if actual != tt.expected {
				t.Errorf("Got: %q; expected: %q", actual, tt.expected)
			}
		})
	}
}

func TestIsFunnelOn(t *testing.T) {
	tests := []struct {
		name string
		sc   *ServeConfig
		want bool
	}{
		{
			name: "nil_config",
		},
		{
			name: "empty_config",
			sc:   &ServeConfig{},
		},
		{
			name: "funnel_enabled_in_background",
			sc: &ServeConfig{
				AllowFunnel: map[HostPort]bool{
					"tailnet.xyz:443": true,
				},
			},
			want: true,
		},
		{
			name: "funnel_disabled_in_background",
			sc: &ServeConfig{
				AllowFunnel: map[HostPort]bool{
					"tailnet.xyz:443": false,
				},
			},
		},
		{
			name: "funnel_enabled_in_foreground",
			sc: &ServeConfig{
				Foreground: map[string]*ServeConfig{
					"abc123": {
						AllowFunnel: map[HostPort]bool{
							"tailnet.xyz:443": true,
						},
					},
				},
			},
			want: true,
		},
		{
			name: "funnel_disabled_in_both",
			sc: &ServeConfig{
				AllowFunnel: map[HostPort]bool{
					"tailnet.xyz:443": false,
				},
				Foreground: map[string]*ServeConfig{
					"abc123": {
						AllowFunnel: map[HostPort]bool{
							"tailnet.xyz:8443": false,
						},
					},
				},
			},
		},
		{
			name: "funnel_enabled_in_both",
			sc: &ServeConfig{
				AllowFunnel: map[HostPort]bool{
					"tailnet.xyz:443": true,
				},
				Foreground: map[string]*ServeConfig{
					"abc123": {
						AllowFunnel: map[HostPort]bool{
							"tailnet.xyz:8443": true,
						},
					},
				},
			},
			want: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := tt.sc.IsFunnelOn(); got != tt.want {
				t.Errorf("ServeConfig.IsFunnelOn() = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestIsServingUnixAny(t *testing.T) {
	tests := []struct {
		name string
		sc   *ServeConfig
		want bool
	}{
		{
			name: "empty",
			sc:   &ServeConfig{},
			want: false,
		},
		{
			name: "nil",
			sc:   &ServeConfig{},
			want: false,
		},
		{
			name: "no_unix",
			sc: &ServeConfig{
				TCP: map[uint16]*TCPPortHandler{
					80: {TCPForward: "localhost:8080"},
				},
			},
			want: false,
		},
		{
			name: "tcp_to_unix",
			sc: &ServeConfig{
				TCP: map[uint16]*TCPPortHandler{
					80: {TCPForward: "unix:/var/run/foo.sock"},
				},
			},
			want: true,
		},
		{
			name: "web_to_unix",
			sc: &ServeConfig{
				Web: map[HostPort]*WebServerConfig{
					"foo.test.ts.net:80": {
						Handlers: map[string]*HTTPHandler{
							"/": {Proxy: "unix:/var/run/foo.sock"},
						},
					},
				},
			},
			want: true,
		},
		{
			name: "foreground_tcp_to_unix",
			sc: &ServeConfig{
				Foreground: map[string]*ServeConfig{
					"abc": {
						TCP: map[uint16]*TCPPortHandler{
							80: {TCPForward: "unix:/var/run/foo.sock"},
						},
					},
				},
			},
			want: true,
		},
		{
			name: "foreground_web_to_unix",
			sc: &ServeConfig{
				Foreground: map[string]*ServeConfig{
					"abc": {
						Web: map[HostPort]*WebServerConfig{
							"foo.test.ts.net:80": {
								Handlers: map[string]*HTTPHandler{
									"/": {Proxy: "unix:/var/run/foo.sock"},
								},
							},
						},
					},
				},
			},
			want: true,
		},
		{
			name: "services_tcp_to_unix",
			sc: &ServeConfig{
				Services: map[tailcfg.ServiceName]*ServiceConfig{
					"svc:foo": {
						TCP: map[uint16]*TCPPortHandler{
							80: {TCPForward: "unix:/var/run/foo.sock"},
						},
					},
				},
			},
			want: true,
		},
		{
			name: "services_web_to_unix",
			sc: &ServeConfig{
				Services: map[tailcfg.ServiceName]*ServiceConfig{
					"svc:foo": {
						Web: map[HostPort]*WebServerConfig{
							"foo.test.ts.net:80": {
								Handlers: map[string]*HTTPHandler{
									"/": {Proxy: "unix:/var/run/foo.sock"},
								},
							},
						},
					},
				},
			},
			want: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := tt.sc.IsServingUnixAny(); got != tt.want {
				t.Errorf("ServeConfig.IsServingUnixAny() = %v, want %v", got, tt.want)
			}
		})
	}
}

// The structural checks below guard against new vulnerabilities in serving Unix
// socket targets. If [ServeConfig] or [ServiceConfig] is updated, these checks
// will fail, forcing you to consider updating [ServeConfig.IsServingUnixAny].
//
// If your updates have created a new way to serve Unix sockets, please update
// IsServingUnixAny accordingly. Otherwise, you can just add your new field
// below and move on.
//
// IsServingUnixAny helps avoid a class of vulnerabilities in which tailscaled
// (by means of tailscale serve) gives non-root users access to Unix sockets
// they otherwise would not have access to (e.g. /var/run/docker.sock). As of
// 2026-06-03, serving Unix sockets at all requires root permissions, and
// IsServingUnixAny is how tailscaled knows when to enforce this restriction.
//
// See https://github.com/tailscale/corp/issues/41998
var _ ServeConfig = struct {
	TCP         map[uint16]*TCPPortHandler             `json:",omitempty"`
	Web         map[HostPort]*WebServerConfig          `json:",omitempty"`
	Services    map[tailcfg.ServiceName]*ServiceConfig `json:",omitempty"`
	AllowFunnel map[HostPort]bool                      `json:",omitempty"`
	Foreground  map[string]*ServeConfig                `json:",omitempty"`
	ETag        string                                 `json:"-"`
}{}
var _ ServiceConfig = struct {
	TCP map[uint16]*TCPPortHandler    `json:",omitempty"`
	Web map[HostPort]*WebServerConfig `json:",omitempty"`
	Tun bool                          `json:",omitempty"`
}{}
