continuous-integration/drone Build is failing
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.
224 lines
8.3 KiB
Go
224 lines
8.3 KiB
Go
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"
|
||
}
|