carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package awsinv
2
3import (
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.
24var joinedRecordTypes = map[string]bool{"A": true, "AAAA": true}
25
26func 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.
73func 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
91func 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).
189func 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
215func 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
237func 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.
262func 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").
296func 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