#5 的 AWS 适配器只走 Resource Groups Tagging API GetResources,仅覆盖「已打标签」资源, 不含未打标签的 EC2/RDS/S3 等(代码已据实声明,#5 全量诉求未据此关闭)。本次补齐: - 新增服务级发现(aws_resource_discovery_services.go):EC2 DescribeInstances、 RDS DescribeDBInstances、S3 ListBuckets、Lambda ListFunctions,均复用已通过 AWS get-vanilla 向量验证的 SigV4 签名(awsSigV4Authorization),无 AWS SDK 依赖。 - discoverAWS 改为编排:tagged + 各服务级结果按 ARN 合并去重(tagged 优先保留,标签信息更全); 原 tagged 逻辑保留为 discoverAWSTagged。 - 失败降级:单服务调用失败(如缺该服务读权限)只记日志并跳过,不让整次发现失败;仅当 tagged 报错且无任何结果时才抛原始错误,保证凭据/区域问题可见。 - 区域:EC2/RDS/Lambda 用账号配置 region;S3 ListBuckets 全局(us-east-1 签名)。跨 region 扫描不在本次范围(单 region),后续可在 #62 跟踪。 安全:沿用 applyDiscoveredResourceFields 既有约束——发现出的资源 SecretRef 恒空,绝不继承 账号 secret_ref;凭据仍走 cloud_account secret 链路,不落代码/日志。 影响面:仅 HM 内部云资源发现;不涉及 Client、AM、agent_swarm、计费、审计字段、密钥写入。 测试:parseEC2Instances/parseRDSInstances/parseS3Buckets/parseLambdaFunctions(真实响应 样本 XML/JSON)+ mergeDedupeByExternalID(去重/优先/空 id);SigV4 向量与既有发现测试不变; go build ./... 与 controller 测试全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
297 lines
9.7 KiB
Go
297 lines
9.7 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/heicode/manager/common"
|
|
"github.com/heicode/manager/model"
|
|
)
|
|
|
|
// AWS resource discovery (#5). Pure-Go AWS Signature V4 (no AWS SDK), mirroring
|
|
// the REST approach of azure_resource_discovery.go. Uses the Resource Groups
|
|
// Tagging API GetResources (AWS JSON 1.1), then normalizes ARNs to discoveredCloudResource.
|
|
//
|
|
// ⚠️ 覆盖范围:GetResources 仅返回**已打标签或曾打标签**的资源,**不覆盖完全未打标签的
|
|
// EC2/RDS/S3 等**——本函数(discoverAWSTagged)是「**tagged resources only**」的一半。
|
|
// 全量发现由 discoverAWS(aws_resource_discovery_services.go,#62)把本结果与服务级
|
|
// List/Describe(DescribeInstances / DescribeDBInstances / ListBuckets / ListFunctions)
|
|
// 按 ARN 合并去重补齐。
|
|
//
|
|
// Creds (from cloud_account metadata/secret): access_key_id, secret_access_key,
|
|
// region, optional session_token.
|
|
|
|
type awsCloudDiscoveryProvider struct{}
|
|
|
|
func (awsCloudDiscoveryProvider) name() string { return "aws" }
|
|
|
|
type awsDiscoveryCredentials struct {
|
|
AccessKeyID string
|
|
SecretAccessKey string
|
|
SessionToken string
|
|
Region string
|
|
}
|
|
|
|
func (c awsDiscoveryCredentials) validate() error {
|
|
if strings.TrimSpace(c.AccessKeyID) == "" {
|
|
return errors.New("AWS access_key_id required")
|
|
}
|
|
if strings.TrimSpace(c.SecretAccessKey) == "" {
|
|
return errors.New("AWS secret_access_key required")
|
|
}
|
|
if strings.TrimSpace(c.Region) == "" {
|
|
return errors.New("AWS region required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (awsCloudDiscoveryProvider) discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error) {
|
|
// region may live on the binding's external_id when not in creds.
|
|
if mapString(creds, "region") == "" && strings.TrimSpace(account.ExternalId) != "" {
|
|
creds["region"] = account.ExternalId
|
|
}
|
|
return discoverAWS(creds, time.Now(), &http.Client{Timeout: 20 * time.Second})
|
|
}
|
|
|
|
// hmacSHA256 / sha256Hex — SigV4 primitives.
|
|
func hmacSHA256(key, data []byte) []byte {
|
|
h := hmac.New(sha256.New, key)
|
|
h.Write(data)
|
|
return h.Sum(nil)
|
|
}
|
|
|
|
func sha256Hex(data []byte) string {
|
|
sum := sha256.Sum256(data)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// awsSigV4Authorization computes the SigV4 Authorization header value + x-amz-date
|
|
// for the given request. signHeaders are the headers (besides host/x-amz-date) to
|
|
// include in the signature (e.g. content-type, x-amz-target). Pure & deterministic
|
|
// given t — verified against AWS's official "get-vanilla" test vector.
|
|
func awsSigV4Authorization(method, rawURL string, signHeaders map[string]string, payload []byte,
|
|
cred awsDiscoveryCredentials, service string, t time.Time) (authorization, amzDate string, err error) {
|
|
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
amzDate = t.UTC().Format("20060102T150405Z")
|
|
dateStamp := t.UTC().Format("20060102")
|
|
|
|
// Canonical headers must include host + x-amz-date. Lowercase names, trim values, sort.
|
|
headers := map[string]string{}
|
|
for k, v := range signHeaders {
|
|
headers[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
|
|
}
|
|
headers["host"] = u.Host
|
|
headers["x-amz-date"] = amzDate
|
|
if strings.TrimSpace(cred.SessionToken) != "" {
|
|
headers["x-amz-security-token"] = strings.TrimSpace(cred.SessionToken)
|
|
}
|
|
names := make([]string, 0, len(headers))
|
|
for k := range headers {
|
|
names = append(names, k)
|
|
}
|
|
sort.Strings(names)
|
|
var canonicalHeaders strings.Builder
|
|
for _, n := range names {
|
|
canonicalHeaders.WriteString(n + ":" + headers[n] + "\n")
|
|
}
|
|
signedHeaders := strings.Join(names, ";")
|
|
|
|
canonicalURI := u.EscapedPath()
|
|
if canonicalURI == "" {
|
|
canonicalURI = "/"
|
|
}
|
|
// Canonical query string: sort by key, RFC3986-encoded.
|
|
canonicalQuery := canonicalizeQuery(u.Query())
|
|
|
|
payloadHash := sha256Hex(payload)
|
|
canonicalRequest := method + "\n" + canonicalURI + "\n" + canonicalQuery + "\n" +
|
|
canonicalHeaders.String() + "\n" + signedHeaders + "\n" + payloadHash
|
|
|
|
credentialScope := dateStamp + "/" + cred.Region + "/" + service + "/aws4_request"
|
|
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex([]byte(canonicalRequest))
|
|
|
|
kDate := hmacSHA256([]byte("AWS4"+cred.SecretAccessKey), []byte(dateStamp))
|
|
kRegion := hmacSHA256(kDate, []byte(cred.Region))
|
|
kService := hmacSHA256(kRegion, []byte(service))
|
|
kSigning := hmacSHA256(kService, []byte("aws4_request"))
|
|
signature := hex.EncodeToString(hmacSHA256(kSigning, []byte(stringToSign)))
|
|
|
|
authorization = fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
|
|
cred.AccessKeyID, credentialScope, signedHeaders, signature)
|
|
return authorization, amzDate, nil
|
|
}
|
|
|
|
func canonicalizeQuery(values url.Values) string {
|
|
if len(values) == 0 {
|
|
return ""
|
|
}
|
|
keys := make([]string, 0, len(values))
|
|
for k := range values {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
parts := make([]string, 0, len(keys))
|
|
for _, k := range keys {
|
|
vs := append([]string(nil), values[k]...)
|
|
sort.Strings(vs)
|
|
for _, v := range vs {
|
|
parts = append(parts, awsURIEncode(k, true)+"="+awsURIEncode(v, true))
|
|
}
|
|
}
|
|
return strings.Join(parts, "&")
|
|
}
|
|
|
|
// awsURIEncode is RFC3986 percent-encoding per SigV4 rules.
|
|
func awsURIEncode(s string, encodeSlash bool) string {
|
|
var b strings.Builder
|
|
for _, c := range []byte(s) {
|
|
switch {
|
|
case (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
|
|
c == '-' || c == '_' || c == '.' || c == '~':
|
|
b.WriteByte(c)
|
|
case c == '/' && !encodeSlash:
|
|
b.WriteByte(c)
|
|
default:
|
|
b.WriteString(fmt.Sprintf("%%%02X", c))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// awsResourceTagMapping is one entry of the Resource Groups Tagging API response.
|
|
type awsResourceTagMapping struct {
|
|
ResourceARN string `json:"ResourceARN"`
|
|
Tags []struct {
|
|
Key string `json:"Key"`
|
|
Value string `json:"Value"`
|
|
} `json:"Tags"`
|
|
}
|
|
|
|
type awsGetResourcesResponse struct {
|
|
ResourceTagMappingList []awsResourceTagMapping `json:"ResourceTagMappingList"`
|
|
PaginationToken string `json:"PaginationToken"`
|
|
}
|
|
|
|
// discoverAWSTagged enumerates the account's tagged resources via the Resource Groups
|
|
// Tagging API (paginated). Real implementation (no mock). Coverage is "tagged only"
|
|
// (see file header); discoverAWS (aws_resource_discovery_services.go) merges this with
|
|
// service-level List/Describe for full-account coverage (#62).
|
|
func discoverAWSTagged(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
|
|
endpoint := fmt.Sprintf("https://tagging.%s.amazonaws.com/", cred.Region)
|
|
const target = "ResourceGroupsTaggingAPI_20170126.GetResources"
|
|
|
|
out := []discoveredCloudResource{}
|
|
paginationToken := ""
|
|
for page := 0; page < 50; page++ {
|
|
bodyMap := map[string]any{"ResourcesPerPage": 100}
|
|
if paginationToken != "" {
|
|
bodyMap["PaginationToken"] = paginationToken
|
|
}
|
|
body, err := common.Marshal(bodyMap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
signHeaders := map[string]string{
|
|
"content-type": "application/x-amz-json-1.1",
|
|
"x-amz-target": target,
|
|
}
|
|
auth, amzDate, err := awsSigV4Authorization(http.MethodPost, endpoint, signHeaders, body, cred, "tagging", now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
|
|
req.Header.Set("X-Amz-Target", target)
|
|
req.Header.Set("X-Amz-Date", amzDate)
|
|
req.Header.Set("Authorization", auth)
|
|
if strings.TrimSpace(cred.SessionToken) != "" {
|
|
req.Header.Set("X-Amz-Security-Token", cred.SessionToken)
|
|
}
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("AWS GetResources request failed: %w", err)
|
|
}
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
|
resp.Body.Close()
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
return nil, fmt.Errorf("AWS GetResources failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
|
}
|
|
var payload awsGetResourcesResponse
|
|
if err := common.Unmarshal(raw, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, m := range payload.ResourceTagMappingList {
|
|
if d, ok := awsARNToResource(m.ResourceARN); ok {
|
|
tags := map[string]any{}
|
|
for _, t := range m.Tags {
|
|
tags[t.Key] = t.Value
|
|
}
|
|
d.Metadata = map[string]any{"region": cred.Region, "tags": tags}
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
paginationToken = strings.TrimSpace(payload.PaginationToken)
|
|
if paginationToken == "" {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// awsARNToResource parses arn:partition:service:region:account:resource into a
|
|
// normalized resource. NativeType is CloudFormation-style (AWS::<Service>::<Type>)
|
|
// so classifyCloudResourceType maps it to vm/database/blob consistently.
|
|
func awsARNToResource(arn string) (discoveredCloudResource, bool) {
|
|
arn = strings.TrimSpace(arn)
|
|
if !strings.HasPrefix(arn, "arn:") {
|
|
return discoveredCloudResource{}, false
|
|
}
|
|
parts := strings.SplitN(arn, ":", 6)
|
|
if len(parts) < 6 {
|
|
return discoveredCloudResource{}, false
|
|
}
|
|
service := parts[2]
|
|
region := parts[3]
|
|
resource := parts[5]
|
|
// resource may be "type/id", "type:id", or just "id" (e.g. s3 bucket).
|
|
resType, resName := "", resource
|
|
if i := strings.IndexAny(resource, "/:"); i >= 0 {
|
|
resType = resource[:i]
|
|
resName = resource[i+1:]
|
|
}
|
|
native := "AWS::" + strings.ToUpper(service)
|
|
if resType != "" {
|
|
native += "::" + resType
|
|
} else if strings.EqualFold(service, "s3") {
|
|
native += "::Bucket"
|
|
}
|
|
name := resName
|
|
if name == "" {
|
|
name = arn
|
|
}
|
|
return discoveredCloudResource{
|
|
ExternalId: arn,
|
|
Name: name,
|
|
NativeType: native,
|
|
Location: region,
|
|
BindingScope: "aws:" + arn,
|
|
}, true
|
|
}
|