| 1 | package awsinv |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "strconv" |
| 7 | "strings" |
| 8 | |
| 9 | "github.com/aws/aws-sdk-go-v2/aws" |
| 10 | "github.com/aws/aws-sdk-go-v2/service/acm" |
| 11 | "github.com/aws/aws-sdk-go-v2/service/cloudwatch" |
| 12 | "github.com/aws/aws-sdk-go-v2/service/iam" |
| 13 | "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" |
| 14 | "github.com/aws/aws-sdk-go-v2/service/route53" |
| 15 | r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" |
| 16 | "github.com/aws/aws-sdk-go-v2/service/s3" |
| 17 | "github.com/carlosframework/platform/internal/infraview" |
| 18 | ) |
| 19 | |
| 20 | // joinedRecordTypes are the record types that can participate in the |
| 21 | // DNS -> IP -> machine join, and so the only ones materialised as their own |
| 22 | // Resource. Everything else is counted into the zone's record_count: a zone |
| 23 | // can hold thousands of records and this object is read on every page load. |
| 24 | var joinedRecordTypes = map[string]bool{"A": true, "AAAA": true} |
| 25 | |
| 26 | func s3Resources(ctx context.Context, api S3API) (out []infraview.Resource, partial []string) { |
| 27 | o, err := api.ListBuckets(ctx, &s3.ListBucketsInput{}) |
| 28 | if err != nil { |
| 29 | return nil, []string{fmt.Sprintf("buckets: %v", err)} |
| 30 | } |
| 31 | if o == nil { |
| 32 | return nil, partial |
| 33 | } |
| 34 | for _, b := range o.Buckets { |
| 35 | name := aws.ToString(b.Name) |
| 36 | r := res(infraview.KindBucket, "s3:bucket", name, name, "") |
| 37 | // Each of the four posture reads below is best-effort per bucket: a |
| 38 | // bucket whose versioning cannot be read still appears, with the |
| 39 | // attribute absent rather than a false "off". "off" is a claim. |
| 40 | if loc, err := api.GetBucketLocation(ctx, &s3.GetBucketLocationInput{Bucket: b.Name}); err == nil && loc != nil { |
| 41 | region := string(loc.LocationConstraint) |
| 42 | if region == "" { |
| 43 | region = "us-east-1" // the API's documented empty-means-us-east-1 |
| 44 | } |
| 45 | r.Region = region |
| 46 | } |
| 47 | if v, err := api.GetBucketVersioning(ctx, &s3.GetBucketVersioningInput{Bucket: b.Name}); err == nil && v != nil { |
| 48 | set(&r, "versioning", strings.ToLower(string(v.Status))) |
| 49 | } |
| 50 | if _, err := api.GetBucketEncryption(ctx, &s3.GetBucketEncryptionInput{Bucket: b.Name}); err == nil { |
| 51 | set(&r, "encryption", "on") |
| 52 | } |
| 53 | if p, err := api.GetPublicAccessBlock(ctx, &s3.GetPublicAccessBlockInput{Bucket: b.Name}); err == nil && p != nil && p.PublicAccessBlockConfiguration != nil { |
| 54 | c := p.PublicAccessBlockConfiguration |
| 55 | if aws.ToBool(c.BlockPublicAcls) && aws.ToBool(c.BlockPublicPolicy) { |
| 56 | set(&r, "public_access_block", "on") |
| 57 | } else { |
| 58 | set(&r, "public_access_block", "partial") |
| 59 | } |
| 60 | } |
| 61 | out = append(out, r) |
| 62 | } |
| 63 | return out, partial |
| 64 | } |
| 65 | |
| 66 | // unescapeDNSName turns Route53's wire form of a record name into the one |
| 67 | // an operator recognises. The API returns non-ASCII and special characters |
| 68 | // octal-escaped, so the wildcard every CARLOS deployment leans on comes back |
| 69 | // as `\052.woodstar.app` — which is what the architecture page displayed |
| 70 | // until someone looked at it. Only the printable-ASCII escapes are decoded; |
| 71 | // anything else is left exactly as the API sent it, since a half-decoded |
| 72 | // name would be worse than an escaped one. |
| 73 | func unescapeDNSName(s string) string { |
| 74 | if !strings.Contains(s, `\`) { |
| 75 | return s |
| 76 | } |
| 77 | var b strings.Builder |
| 78 | for i := 0; i < len(s); i++ { |
| 79 | if s[i] == '\\' && i+3 < len(s) { |
| 80 | if n, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil && n >= 0x20 && n < 0x7f { |
| 81 | b.WriteByte(byte(n)) |
| 82 | i += 3 |
| 83 | continue |
| 84 | } |
| 85 | } |
| 86 | b.WriteByte(s[i]) |
| 87 | } |
| 88 | return b.String() |
| 89 | } |
| 90 | |
| 91 | func route53Resources(ctx context.Context, api Route53API) (out []infraview.Resource, partial []string) { |
| 92 | zones, err := api.ListHostedZones(ctx, &route53.ListHostedZonesInput{}) |
| 93 | if err != nil { |
| 94 | return nil, []string{fmt.Sprintf("hosted zones: %v", err)} |
| 95 | } |
| 96 | if zones == nil { |
| 97 | return nil, partial |
| 98 | } |
| 99 | for _, z := range zones.HostedZones { |
| 100 | // AWS's own ID field carries an "/hostedzone/" prefix |
| 101 | // (aws.ToString(z.Id) == "/hostedzone/Z1"); the bare "Z1" is the |
| 102 | // canonical hosted-zone id used everywhere else (the console, the |
| 103 | // CLI, parseARN's tail), and nothing downstream depends on the |
| 104 | // prefixed form — the console joins records to their zone by NAME, |
| 105 | // not by id. Using the bare id here is what lets the catch-all's |
| 106 | // dedup, keyed on the literal Resource.ID, find this zone without |
| 107 | // a second, service-specific seeding rule. |
| 108 | id := strings.TrimPrefix(aws.ToString(z.Id), "/hostedzone/") |
| 109 | name := unescapeDNSName(strings.TrimSuffix(aws.ToString(z.Name), ".")) |
| 110 | zr := res(infraview.KindDNSZone, "route53:hosted-zone", id, name, "") |
| 111 | if z.Config != nil && z.Config.PrivateZone { |
| 112 | set(&zr, "private", "yes") |
| 113 | } |
| 114 | count := 0 |
| 115 | // Route53 needs the full StartRecordName+StartRecordType+ |
| 116 | // StartRecordIdentifier triple to resume at the exact record; a |
| 117 | // name-only cursor restarts each page at the FIRST record carrying |
| 118 | // that name, which both double-counts records at the page boundary |
| 119 | // and, when a single name owns a whole page, never advances at all. |
| 120 | var nextName, nextIdentifier *string |
| 121 | var nextType r53types.RRType |
| 122 | for { |
| 123 | recs, err := api.ListResourceRecordSets(ctx, &route53.ListResourceRecordSetsInput{ |
| 124 | HostedZoneId: z.Id, StartRecordName: nextName, |
| 125 | StartRecordType: nextType, StartRecordIdentifier: nextIdentifier, |
| 126 | }) |
| 127 | if err != nil { |
| 128 | partial = append(partial, fmt.Sprintf("records in %s: %v", name, err)) |
| 129 | break |
| 130 | } |
| 131 | if recs == nil { |
| 132 | break |
| 133 | } |
| 134 | for _, rec := range recs.ResourceRecordSets { |
| 135 | count++ |
| 136 | rtype := string(rec.Type) |
| 137 | if !joinedRecordTypes[rtype] { |
| 138 | continue |
| 139 | } |
| 140 | rname := unescapeDNSName(strings.TrimSuffix(aws.ToString(rec.Name), ".")) |
| 141 | for _, v := range rec.ResourceRecords { |
| 142 | rr := res(infraview.KindDNSRecord, "route53:record", rname+"/"+rtype+"/"+aws.ToString(v.Value), rname, "") |
| 143 | set(&rr, "zone", name) |
| 144 | set(&rr, "type", rtype) |
| 145 | set(&rr, "value", aws.ToString(v.Value)) |
| 146 | out = append(out, rr) |
| 147 | } |
| 148 | if rec.AliasTarget != nil { |
| 149 | rr := res(infraview.KindDNSRecord, "route53:alias", rname+"/ALIAS", rname, "") |
| 150 | set(&rr, "zone", name) |
| 151 | set(&rr, "type", "ALIAS") |
| 152 | set(&rr, "value", strings.TrimSuffix(aws.ToString(rec.AliasTarget.DNSName), ".")) |
| 153 | out = append(out, rr) |
| 154 | } |
| 155 | } |
| 156 | if !recs.IsTruncated { |
| 157 | break |
| 158 | } |
| 159 | // A nil next-name, or a next name+type+identifier triple |
| 160 | // identical to what we just asked for, means the cursor did not |
| 161 | // advance. All three must match to call it a stall: weighted, |
| 162 | // latency, geolocation and failover record sets legitimately |
| 163 | // repeat the same name and type across a page boundary while |
| 164 | // only the identifier moves, and treating that as a stall would |
| 165 | // truncate the record set. Trusting IsTruncated forever on a |
| 166 | // genuine non-advance would spin an unbounded loop against a |
| 167 | // live API on an hourly pass, so stop and name the shortfall |
| 168 | // rather than loop or restart the zone. |
| 169 | if recs.NextRecordName == nil || |
| 170 | (aws.ToString(recs.NextRecordName) == aws.ToString(nextName) && |
| 171 | recs.NextRecordType == nextType && |
| 172 | aws.ToString(recs.NextRecordIdentifier) == aws.ToString(nextIdentifier)) { |
| 173 | partial = append(partial, fmt.Sprintf("records in %s: pagination cursor did not advance, stopped short", name)) |
| 174 | break |
| 175 | } |
| 176 | nextName = recs.NextRecordName |
| 177 | nextType = recs.NextRecordType |
| 178 | nextIdentifier = recs.NextRecordIdentifier |
| 179 | } |
| 180 | set(&zr, "record_count", strconv.Itoa(count)) |
| 181 | out = append(out, zr) |
| 182 | } |
| 183 | return out, partial |
| 184 | } |
| 185 | |
| 186 | // iamResources reports identities as NAMES and a count of attached |
| 187 | // policies. Policy documents are deliberately never read: this page is a |
| 188 | // map, not a credential viewer (spec §4). |
| 189 | func iamResources(ctx context.Context, api IAMAPI) (out []infraview.Resource, partial []string) { |
| 190 | if o, err := api.ListRoles(ctx, &iam.ListRolesInput{}); err != nil { |
| 191 | partial = append(partial, fmt.Sprintf("IAM roles: %v", err)) |
| 192 | } else if o != nil { |
| 193 | for _, role := range o.Roles { |
| 194 | name := aws.ToString(role.RoleName) |
| 195 | r := res(infraview.KindIdentity, "iam:role", aws.ToString(role.Arn), name, "") |
| 196 | set(&r, "kind", "role") |
| 197 | if pol, err := api.ListAttachedRolePolicies(ctx, &iam.ListAttachedRolePoliciesInput{RoleName: role.RoleName}); err == nil && pol != nil { |
| 198 | set(&r, "attached_policies", strconv.Itoa(len(pol.AttachedPolicies))) |
| 199 | } |
| 200 | out = append(out, r) |
| 201 | } |
| 202 | } |
| 203 | if o, err := api.ListUsers(ctx, &iam.ListUsersInput{}); err != nil { |
| 204 | partial = append(partial, fmt.Sprintf("IAM users: %v", err)) |
| 205 | } else if o != nil { |
| 206 | for _, u := range o.Users { |
| 207 | r := res(infraview.KindIdentity, "iam:user", aws.ToString(u.Arn), aws.ToString(u.UserName), "") |
| 208 | set(&r, "kind", "user") |
| 209 | out = append(out, r) |
| 210 | } |
| 211 | } |
| 212 | return out, partial |
| 213 | } |
| 214 | |
| 215 | func alarmResources(ctx context.Context, api CloudWatchAPI, region string) (out []infraview.Resource, partial []string) { |
| 216 | o, err := api.DescribeAlarms(ctx, &cloudwatch.DescribeAlarmsInput{}) |
| 217 | if err != nil { |
| 218 | return nil, []string{fmt.Sprintf("alarms in %s: %v", region, err)} |
| 219 | } |
| 220 | if o == nil { |
| 221 | return nil, partial |
| 222 | } |
| 223 | for _, a := range o.MetricAlarms { |
| 224 | r := res(infraview.KindAlarm, "cloudwatch:alarm", aws.ToString(a.AlarmArn), aws.ToString(a.AlarmName), region) |
| 225 | set(&r, "state", string(a.StateValue)) |
| 226 | set(&r, "metric", aws.ToString(a.MetricName)) |
| 227 | for _, d := range a.Dimensions { |
| 228 | if aws.ToString(d.Name) == "InstanceId" { |
| 229 | set(&r, "target", aws.ToString(d.Value)) |
| 230 | } |
| 231 | } |
| 232 | out = append(out, r) |
| 233 | } |
| 234 | return out, partial |
| 235 | } |
| 236 | |
| 237 | func certResources(ctx context.Context, api ACMAPI, region string) (out []infraview.Resource, partial []string) { |
| 238 | o, err := api.ListCertificates(ctx, &acm.ListCertificatesInput{}) |
| 239 | if err != nil { |
| 240 | return nil, []string{fmt.Sprintf("certificates in %s: %v", region, err)} |
| 241 | } |
| 242 | if o == nil { |
| 243 | return nil, partial |
| 244 | } |
| 245 | for _, c := range o.CertificateSummaryList { |
| 246 | r := res(infraview.KindCertificate, "acm:certificate", aws.ToString(c.CertificateArn), aws.ToString(c.DomainName), region) |
| 247 | set(&r, "domain", aws.ToString(c.DomainName)) |
| 248 | set(&r, "status", string(c.Status)) |
| 249 | if c.NotAfter != nil { |
| 250 | set(&r, "expires", c.NotAfter.UTC().Format("2006-01-02")) |
| 251 | } |
| 252 | out = append(out, r) |
| 253 | } |
| 254 | return out, partial |
| 255 | } |
| 256 | |
| 257 | // catchAll is the one genuinely AWS-shaped trick in this package: one call |
| 258 | // that returns every TAGGABLE resource in the region, so a resource type |
| 259 | // nobody wrote a mapping for is still visible. seen holds the ids the typed |
| 260 | // mappings already produced — an ARN whose id is in it is skipped, so the |
| 261 | // catch-all only ever ADDS. |
| 262 | func catchAll(ctx context.Context, api TaggingAPI, region string, seen map[string]bool) (out []infraview.Resource, partial []string) { |
| 263 | var token *string |
| 264 | for { |
| 265 | o, err := api.GetResources(ctx, &resourcegroupstaggingapi.GetResourcesInput{PaginationToken: token}) |
| 266 | if err != nil { |
| 267 | return out, append(partial, fmt.Sprintf("resource inventory in %s: %v", region, err)) |
| 268 | } |
| 269 | if o == nil { |
| 270 | return out, partial |
| 271 | } |
| 272 | for _, m := range o.ResourceTagMappingList { |
| 273 | arn := aws.ToString(m.ResourceARN) |
| 274 | service, id := parseARN(arn) |
| 275 | if id == "" || seen[id] || seen[arn] { |
| 276 | continue |
| 277 | } |
| 278 | r := res(infraview.KindOther, service, arn, id, region) |
| 279 | for _, t := range m.Tags { |
| 280 | if r.Tags == nil { |
| 281 | r.Tags = map[string]string{} |
| 282 | } |
| 283 | r.Tags[aws.ToString(t.Key)] = aws.ToString(t.Value) |
| 284 | } |
| 285 | out = append(out, r) |
| 286 | } |
| 287 | if o.PaginationToken == nil || *o.PaginationToken == "" { |
| 288 | return out, partial |
| 289 | } |
| 290 | token = o.PaginationToken |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | // parseARN pulls the service and the trailing resource id out of an ARN: |
| 295 | // arn:aws:ec2:eu-west-1:1234:instance/i-123 -> ("ec2", "i-123"). |
| 296 | func parseARN(arn string) (service, id string) { |
| 297 | parts := strings.SplitN(arn, ":", 6) |
| 298 | if len(parts) < 6 { |
| 299 | return "", "" |
| 300 | } |
| 301 | service = parts[2] |
| 302 | tail := parts[5] |
| 303 | if i := strings.LastIndexAny(tail, "/:"); i >= 0 { |
| 304 | tail = tail[i+1:] |
| 305 | } |
| 306 | return service, tail |
| 307 | } |
| 308 | |