Files
LinkPort/internal/outbound/kubernetes_preflight.go
T
曾志威 9da95beb42
continuous-integration/drone Build is failing
feat: refine management UI, connection diagnostics and local startup
Migrate the workspace to React and Ant Design, improve account forms and responsive layouts, support outbound diagnostics and SSH fingerprint confirmation, and persist the management startup port. Preserve database migrations, remove S3 settings storage, and improve gateway audit handling.
2026-10-13 19:43:23 +08:00

224 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
}