package outbound import ( "context" "errors" "net/http" "net/url" "strings" "time" "linkport/internal/model" authorizationv1 "k8s.io/api/authorization/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" authorizationclient "k8s.io/client-go/kubernetes/typed/authorization/v1" "k8s.io/client-go/tools/clientcmd" ) // KubernetesPermissionCheck is deliberately limited to machine-readable // permission results. It never contains API-server error text or credentials. type KubernetesPermissionCheck struct { Verb string `json:"verb"` Resource string `json:"resource"` Subresource string `json:"subresource,omitempty"` Status string `json:"status"` // allowed, denied, or unknown Code string `json:"code,omitempty"` } type KubernetesPreflight struct { Status string `json:"status"` // ready, denied, or unknown Checks []KubernetesPermissionCheck `json:"checks"` } func validatePreflightKubeconfig(content, contextName string) error { cfg, err := clientcmd.Load([]byte(content)) if err != nil { return discoveryError("kubeconfig_invalid", "Kubeconfig 文件无效,请重新上传") } selected := contextName if selected == "" { selected = cfg.CurrentContext } selectedContext := cfg.Contexts[selected] if selectedContext == nil { return discoveryError("context_not_found", "所选 Context 不存在,请重新选择") } if auth := cfg.AuthInfos[selectedContext.AuthInfo]; auth != nil && (auth.Exec != nil || auth.AuthProvider != nil) { return discoveryError("login_plugin_unsupported", "Kubeconfig 资产不能使用 exec/auth-provider 登录插件;请改用内嵌证书或 Token") } return nil } // PreflightKubernetes checks the permissions needed by the runtime resolver // and port-forward stream. It is advisory: clusters may deny the // SelfSubjectAccessReview API itself, in which case the result is unknown and // the normal startup path remains authoritative. func PreflightKubernetes(ctx context.Context, content, contextName, namespace, resource string, port int) (KubernetesPreflight, error) { if port < 1 || port > 65535 { return KubernetesPreflight{Status: "unknown", Checks: []KubernetesPermissionCheck{}}, discoveryError("invalid_port", "Kubernetes 端口必须为 1–65535") } o := model.Outbound{Mode: "k8s", KubeConfigContent: content, KubeContext: contextName, Namespace: namespace, Resource: resource} return TestKubernetes(ctx, o) } func TestKubernetes(ctx context.Context, o model.Outbound) (KubernetesPreflight, error) { result := KubernetesPreflight{Status: "unknown", Checks: []KubernetesPermissionCheck{}} namespace, resource := o.Namespace, o.Resource if err := o.ValidateKubernetes(); err != nil { if errors.Is(err, model.ErrKubeConfigLoginPlugin) { return result, discoveryError("login_plugin_unsupported", "Kubeconfig 资产不能使用 exec/auth-provider 登录插件;请改用内嵌证书或 Token") } if errors.Is(err, model.ErrKubeConfigExternalFiles) { return result, discoveryError("external_file_reference_unsupported", "Kubeconfig 资产必须内嵌证书、密钥和 Token,不能引用服务端文件路径") } return result, discoveryError("kubernetes_invalid_config", "Kubernetes 资源、命名空间或 Context 配置无效") } if o.KubeConfigContent != "" { if err := validatePreflightKubeconfig(o.KubeConfigContent, o.KubeContext); err != nil { return result, err } } loader, err := kubernetesClientConfig(o) if err != nil { return result, discoveryError("kubeconfig_invalid", "无法加载 Kubeconfig,请检查 Context 和凭据") } config, err := loader.ClientConfig() if err != nil { return result, discoveryError("kubeconfig_invalid", "无法加载 Kubeconfig,请检查 Context 和凭据") } if config.ExecProvider != nil || config.AuthProvider != nil { return result, discoveryError("login_plugin_unsupported", "代理测试不支持 exec/auth-provider 登录插件;请使用内嵌证书或 Token") } if namespace == "" { namespace, _, err = loader.Namespace() if err != nil { return result, discoveryError("namespace_invalid", "无法读取 Kubernetes 命名空间") } } apiURL, err := url.Parse(config.Host) if err != nil { return result, discoveryError("kubeconfig_invalid", "Kubernetes 集群地址无效") } proxy := config.Proxy if proxy == nil { proxy = http.ProxyFromEnvironment } proxyURL, err := proxy(&http.Request{URL: apiURL}) if err != nil || proxyURL != nil { return result, discoveryError("proxy_unsupported", "权限预检暂不支持 Kubeconfig 代理,请使用可直接访问集群的配置") } config.Proxy = func(*http.Request) (*url.URL, error) { return nil, nil } config.Timeout = 8 * time.Second // Authorization fixtures and some API proxies do not decode Kubernetes // protobuf payloads. The review is small, so prefer the portable JSON form. config.ContentConfig.AcceptContentTypes = "application/json" config.ContentConfig.ContentType = "application/json" client, err := authorizationclient.NewForConfig(config) if err != nil { return result, discoveryError("kubernetes_unavailable", "无法创建 Kubernetes 权限检查客户端") } ctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() kind, name, _ := strings.Cut(resource, "/") checks := permissionChecks(kind) for _, spec := range checks { result.Checks = append(result.Checks, KubernetesPermissionCheck{Verb: spec.Verb, Resource: spec.Resource, Subresource: spec.Subresource}) } for i := range result.Checks { current := &result.Checks[i] review := &authorizationv1.SelfSubjectAccessReview{Spec: authorizationv1.SelfSubjectAccessReviewSpec{ResourceAttributes: &authorizationv1.ResourceAttributes{ Namespace: namespace, Verb: current.Verb, Resource: current.Resource, Subresource: current.Subresource, Name: nameForCheck(current, kind, name), Group: groupForResource(current.Resource), Version: versionForResource(current.Resource), }}} response, callErr := client.SelfSubjectAccessReviews().Create(ctx, review, metav1.CreateOptions{}) if callErr != nil { current.Status = "unknown" current.Code = permissionErrorCode(callErr) for next := i + 1; next < len(result.Checks); next++ { result.Checks[next].Status = "unknown" result.Checks[next].Code = current.Code } break } if response.Status.Allowed { current.Status = "allowed" } else if response.Status.Denied { current.Status = "denied" current.Code = "permission_denied" } else { current.Status = "unknown" current.Code = "permission_undetermined" } if response.Status.EvaluationError != "" && current.Status != "denied" { current.Status = "unknown" current.Code = "permission_evaluation_error" } } result.Status = "ready" for _, check := range result.Checks { if check.Status == "denied" { result.Status = "denied" break } if check.Status == "unknown" { result.Status = "unknown" } } return result, nil } type permissionCheckSpec struct { Verb, Resource, Subresource string } func permissionChecks(kind string) []permissionCheckSpec { resource := "pods" if kind == "service" { resource = "services" } else if kind == "deployment" { resource = "deployments" } else if kind == "statefulset" { resource = "statefulsets" } checks := []permissionCheckSpec{{Verb: "get", Resource: resource}} if kind != "pod" { checks = append(checks, permissionCheckSpec{Verb: "list", Resource: "pods"}) } checks = append(checks, permissionCheckSpec{Verb: "create", Resource: "pods", Subresource: "portforward"}) return checks } func nameForCheck(check *KubernetesPermissionCheck, kind, name string) string { if check.Verb == "get" || (kind == "pod" && check.Resource == "pods" && check.Subresource == "portforward") { return name } return "" } func groupForResource(resource string) string { if resource == "deployments" || resource == "statefulsets" { return "apps" } return "" } func versionForResource(resource string) string { if resource == "deployments" || resource == "statefulsets" { return "v1" } return "v1" } func permissionErrorCode(err error) string { if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { return "timeout" } if apierrors.IsForbidden(err) || apierrors.IsUnauthorized(err) { return "authorization_review_denied" } return "authorization_unavailable" }