From 670d9dcd3bc9b12367ea057eb27979a8deb6a0f9 Mon Sep 17 00:00:00 2001 From: Brandon Willett Date: Sat, 20 Jul 2024 14:30:58 -0400 Subject: [PATCH] switch from hostname-based auth to zone-id-based --- Dockerfile | 40 +++++++++++---------------- client/supabase_admin.go | 57 +++++++-------------------------------- client/supabase_normie.go | 42 ++++++++++++++++++++++++++--- cmd/api/api.go | 53 ++++++------------------------------ cmd/intake/enirched.go | 32 +++++++++++----------- cmd/intake/intaker.go | 6 ++--- cmd/query/cmd.go | 2 +- cmd/query/influx.go | 16 +++++------ util/auth.go | 31 ++++++++++++--------- util/claims.go | 27 ++++++++++--------- util/random.go | 27 +++++++++++++------ 11 files changed, 153 insertions(+), 180 deletions(-) diff --git a/Dockerfile b/Dockerfile index 535dae8..c0ac617 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,36 +1,26 @@ -############################ -# STAGE 0 - Build -############################ +FROM ubuntu:24.04 -# https://hub.docker.com/r/jetpackio/devbox/tags -FROM jetpackio/devbox:0.12.0 +WORKDIR /src -WORKDIR /code +# Install some system dependencies +RUN apt-get update && apt-get install -y xz-utils curl git ca-certificates lftp +RUN update-ca-certificates -USER root:root -RUN mkdir -p /code && chown ${DEVBOX_USER}:${DEVBOX_USER} /code -USER ${DEVBOX_USER}:${DEVBOX_USER} -COPY --chown=${DEVBOX_USER}:${DEVBOX_USER} devbox.json devbox.json -COPY --chown=${DEVBOX_USER}:${DEVBOX_USER} devbox.lock devbox.lock +# This is the official way to install it :( +RUN curl -fsSL https://get.jetify.com/devbox -o install-devbox.sh +RUN chmod +x install-devbox.sh +RUN ./install-devbox.sh -f # Do just dependencies first, to benefit from layer caching +COPY devbox.json devbox.json +COPY devbox.lock devbox.lock +# This also installs nix, takes a while, todo, do this earlier in Dockerfile RUN devbox install # Now the rest! - -COPY --chown=${DEVBOX_USER}:${DEVBOX_USER} . . - +COPY . . RUN devbox run build -############################ -# STAGE 1 - Run -############################ - -# https://hub.docker.com/_/ubuntu/tags -FROM ubuntu:22.04 - -RUN apt-get update && apt-get install -y git ca-certificates lftp -RUN update-ca-certificates - -COPY --from=0 /code/out/cbnr /usr/bin/cbnr +# And copy the command to somewhere we can find it +RUN mv /src/out/cbnr /usr/bin/cbnr diff --git a/client/supabase_admin.go b/client/supabase_admin.go index cbd8dfc..400bfd7 100644 --- a/client/supabase_admin.go +++ b/client/supabase_admin.go @@ -24,12 +24,8 @@ type CreateSiteRowBody struct { Hostname string `json:"hostname"` } -type AddHostnameToSiteRowBody struct { - CustomHostname string `json:"custom_hostname"` -} - -type AuthorizeHostnameBody struct { - AppMetadata map[string][]string `json:"app_metadata"` +type AuthorizeZoneIdBody struct { + AppMetadata map[string][]int `json:"app_metadata"` } func (s SupabaseAdminClient) CreateSiteRow(ctx context.Context, userId, siteId, nickname string, storage *CreateStorageZoneResponse, pull *CreatePullZoneResponse) bool { @@ -67,18 +63,18 @@ func (s SupabaseAdminClient) CreateSiteRow(ctx context.Context, userId, siteId, return true } -func (s SupabaseAdminClient) AuthorizeHostname(ctx context.Context, userId, newHostname string, existingHostnames []string) bool { +func (s SupabaseAdminClient) AuthorizeZoneId(ctx context.Context, userId string, newZoneId int, existingZoneIds []int) bool { // mutating in place because Go makes anything else annoyingly difficult - existingHostnames = append(existingHostnames, newHostname) + existingZoneIds = append(existingZoneIds, newZoneId) - body := AuthorizeHostnameBody{ - AppMetadata: map[string][]string{ - "hostnames": existingHostnames, + body := AuthorizeZoneIdBody{ + AppMetadata: map[string][]int{ + "zones": existingZoneIds, }, } - log.Printf("[INFO] Authorizing user %v for hostname with request body: %+v", userId, body) + log.Printf("[INFO] Authorizing user %v for zone with request body: %+v", userId, body) var errorJson map[string]interface{} @@ -94,44 +90,11 @@ func (s SupabaseAdminClient) AuthorizeHostname(ctx context.Context, userId, newH Fetch(ctx) if err != nil { - log.Printf("[ERROR] Unable to create pull zone in BunnyCDN: %v, response: %+v", err, errorJson) - return false - } - - log.Printf("[INFO] Successfully authorized user %v for hostname %v", userId, newHostname) - - return true -} - -// todo - unclear why this needs the admin client, can service role bypass no-update trigger? -func (s SupabaseAdminClient) AddHostnameToSiteRow(ctx context.Context, siteId, hostname string) bool { - - body := AddHostnameToSiteRowBody{ - CustomHostname: hostname, - } - - log.Printf("[INFO] Adding hostname to SITE row %v with request body: %+v", siteId, body) - - var errorJson map[string]interface{} - - err := requests. - URL(s.SupabaseUrl). - Path("/rest/v1/site"). - Param("id", fmt.Sprintf("eq.%v", siteId)). - Header("apikey", s.SupabaseAnonKey). - Header("Authorization", fmt.Sprintf("Bearer %v", s.SupabaseServiceKey)). - ContentType("application/json"). - BodyJSON(&body). - ErrorJSON(&errorJson). - Patch(). - Fetch(ctx) - - if err != nil { - log.Printf("[ERROR] Unable to add hostname to SITE row %v: %v, response: %+v", siteId, err, errorJson) + log.Printf("[ERROR] Unable to authorize user %v for zone: %v, response: %+v", userId, err, errorJson) return false } - log.Printf("[INFO] Successfully added new hostname for site %v, hostname %v", siteId, hostname) + log.Printf("[INFO] Successfully authorized user %v for zone ID %v", userId, newZoneId) return true } diff --git a/client/supabase_normie.go b/client/supabase_normie.go index e972b79..d3b80a4 100644 --- a/client/supabase_normie.go +++ b/client/supabase_normie.go @@ -28,11 +28,15 @@ type SiteRow struct { Hostname string `json:"hostname"` } -type UpdateFieldsBody struct { +type UpdateDeployedShaBody struct { DeployedSha string `json:"deployed_sha"` LastUpdatedAt string `json:"last_updated_at"` } +type AddHostnameToSiteRowBody struct { + CustomHostname string `json:"custom_hostname"` +} + func (s SupabaseNormieClient) GetSiteRow(ctx context.Context, jwt, siteId string) *SiteRow { log.Printf("[INFO] Attempting to fetch row for site ID %v from supabase", siteId) @@ -71,9 +75,9 @@ func (s SupabaseNormieClient) GetSiteRow(ctx context.Context, jwt, siteId string return &rows[0] } -func (s SupabaseNormieClient) UpdateFields(ctx context.Context, jwt, siteId, sha string) bool { +func (s SupabaseNormieClient) UpdateDeployedSha(ctx context.Context, jwt, siteId, sha string) bool { - body := UpdateFieldsBody{ + body := UpdateDeployedShaBody{ DeployedSha: sha, LastUpdatedAt: "now", // special value understood by postgrest, me being lazy } @@ -103,3 +107,35 @@ func (s SupabaseNormieClient) UpdateFields(ctx context.Context, jwt, siteId, sha return true } + +func (s SupabaseNormieClient) AddHostnameToSiteRow(ctx context.Context, jwt, siteId, hostname string) bool { + + body := AddHostnameToSiteRowBody{ + CustomHostname: hostname, + } + + log.Printf("[INFO] Adding hostname to SITE row %v with request body: %+v", siteId, body) + + var errorJson map[string]interface{} + + err := requests. + URL(s.SupabaseUrl). + Path("/rest/v1/site"). + Param("id", fmt.Sprintf("eq.%v", siteId)). + Header("apikey", s.SupabaseAnonKey). + Header("Authorization", jwt). + ContentType("application/json"). + BodyJSON(&body). + ErrorJSON(&errorJson). + Patch(). + Fetch(ctx) + + if err != nil { + log.Printf("[ERROR] Unable to add hostname to SITE row %v: %v, response: %+v", siteId, err, errorJson) + return false + } + + log.Printf("[INFO] Successfully added new hostname for site %v, hostname %v", siteId, hostname) + + return true +} diff --git a/cmd/api/api.go b/cmd/api/api.go index e6ce3be..e6e3a7e 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -2,7 +2,6 @@ package api import ( "encoding/json" - "fmt" "log" "net/http" @@ -62,10 +61,10 @@ func (s Server) CreateSite(out http.ResponseWriter, req *http.Request) { return } - // get the currently-authorized hostnames from the claims for appending later - existingHostnames, err := util.GetHostnamesFromClaims(claims) + // get the currently-authorized sites from the claims for appending later + existingZoneIds, err := util.GetZoneIdsFromClaims(claims) if err != nil { - log.Printf("[ERROR] Unable to get hostnames from JWT claims: %v", err) + log.Printf("[ERROR] Unable to get site IDs from JWT claims: %v", err) http.Error(out, "Invalid JWT app metadata", http.StatusUnauthorized) return } @@ -81,7 +80,7 @@ func (s Server) CreateSite(out http.ResponseWriter, req *http.Request) { } // needed by pretty much all the below functions, so let's gen it here - siteId := fmt.Sprintf("%v-%v-%v", util.RandomString(3), util.RandomString(3), util.RandomString(3)) + siteId := util.RandomIamTriple() log.Printf("[INFO] Creating a new site with generated ID %v...", siteId) storage := s.BunnyAdmin.CreateStorageZone(req.Context(), siteId) @@ -102,9 +101,9 @@ func (s Server) CreateSite(out http.ResponseWriter, req *http.Request) { return } - worked = s.SupaAdmin.AuthorizeHostname(req.Context(), userId, pull.Hostnames[0].Value, existingHostnames) + worked = s.SupaAdmin.AuthorizeZoneId(req.Context(), userId, int(pull.Id), existingZoneIds) if !worked { - http.Error(out, "Unable to authorize user for new hostname", http.StatusInternalServerError) + http.Error(out, "Unable to authorize user for new zone ID", http.StatusInternalServerError) return } @@ -130,36 +129,6 @@ func (s Server) AddHostname(out http.ResponseWriter, req *http.Request) { var err error - _, claims, err := jwtauth.FromContext(req.Context()) - if err != nil { - log.Printf("[ERROR] Unable to parse claims from JWT: %v", err) - http.Error(out, "Unable to parse claims from JWT", http.StatusUnauthorized) - return - } - - // what is "sub"? If nothing else, seems to be the JWT's slang for user ID - userIdUntyped, found := claims["sub"] - if !found { - log.Printf("[ERROR] No 'user_id' field found in JWT claims: %v", claims) - http.Error(out, "Unable to parse claims from JWT", http.StatusUnauthorized) - return - } - - userId, ok := userIdUntyped.(string) - if !ok { - log.Printf("[ERROR] Claims 'user_id' could not be parsed as string") - http.Error(out, "Unable to parse claims from JWT", http.StatusUnauthorized) - return - } - - // get the currently-authorized hostnames from the claims for appending later - existingHostnames, err := util.GetHostnamesFromClaims(claims) - if err != nil { - log.Printf("[ERROR] Unable to get hostnames from JWT claims: %v", err) - http.Error(out, "Invalid JWT app metadata", http.StatusUnauthorized) - return - } - // no need to validate here, impossible to get this far if JWT is invalid jwt := req.Header.Get("Authorization") @@ -212,13 +181,7 @@ func (s Server) AddHostname(out http.ResponseWriter, req *http.Request) { // todo -- rollback the new hostname if the below don't work, too? Agh - worked = s.SupaAdmin.AuthorizeHostname(req.Context(), userId, body.Hostname, existingHostnames) - if !worked { - http.Error(out, "Unable to authorize user for new hostname", http.StatusInternalServerError) - return - } - - worked = s.SupaAdmin.AddHostnameToSiteRow(req.Context(), body.SiteId, body.Hostname) + worked = s.SupaNormie.AddHostnameToSiteRow(req.Context(), jwt, body.SiteId, body.Hostname) if !worked { http.Error(out, "Unable to add new hostname to Supabase row", http.StatusInternalServerError) return @@ -245,7 +208,7 @@ func (s Server) PostPush(out http.ResponseWriter, req *http.Request) { // todo -- validate that sha is not empty and looks like a real SHA - worked := s.SupaNormie.UpdateFields(req.Context(), jwt, body.SiteId, body.Sha) + worked := s.SupaNormie.UpdateDeployedSha(req.Context(), jwt, body.SiteId, body.Sha) if !worked { http.Error(out, "Unable to update site row in Supabase", http.StatusInternalServerError) return diff --git a/cmd/intake/enirched.go b/cmd/intake/enirched.go index 7fe5d4e..d41ed04 100644 --- a/cmd/intake/enirched.go +++ b/cmd/intake/enirched.go @@ -17,11 +17,11 @@ import ( // EnrichedLog is responsible for turning a BunnyLog into a point for influx // with all the necessary tags, timestamps, etc type EnrichedLog struct { - StatusCode int - StatusCategory string + PullZoneId int Timestamp int64 BytesSent int - RemoteIp string + StatusCode int + StatusCategory string Host string Path string Referrer string @@ -36,21 +36,21 @@ type EnrichedLog struct { func Enrich(bunny BunnyLog) EnrichedLog { ua := useragent.Parse(bunny.UserAgent) return EnrichedLog{ + PullZoneId: bunny.PullZoneId, + // bunny comes in epoch ms, CH wants epoch sec + Timestamp: bunny.Timestamp / 1000, + BytesSent: bunny.BytesSent, StatusCode: bunny.Status, StatusCategory: StatusCategory(bunny), - // bunny comes in epoch ms, CH wants epoch sec - Timestamp: bunny.Timestamp / 1000, - BytesSent: bunny.BytesSent, - RemoteIp: bunny.RemoteIp, - Host: bunny.Host, - Path: bunny.PathAndQuery, - Referrer: Referrer(bunny), - Device: Device(ua), - Browser: Browser(ua), - Os: Os(ua), - Country: bunny.Country, - FileType: FileType(bunny), - IsProbablyBot: IsProbablyBot(bunny), + Host: bunny.Host, + Path: bunny.PathAndQuery, + Referrer: Referrer(bunny), + Device: Device(ua), + Browser: Browser(ua), + Os: Os(ua), + Country: bunny.Country, + FileType: FileType(bunny), + IsProbablyBot: IsProbablyBot(bunny), } } diff --git a/cmd/intake/intaker.go b/cmd/intake/intaker.go index b18154b..9e9c4e2 100644 --- a/cmd/intake/intaker.go +++ b/cmd/intake/intaker.go @@ -68,11 +68,11 @@ func (i Intaker) Consume(ctx context.Context) { func addToBatch(batch ch.Batch, enriched EnrichedLog) error { // must match the order in the schema exactly return batch.Append( - enriched.StatusCode, - enriched.StatusCategory, + enriched.PullZoneId, enriched.Timestamp, enriched.BytesSent, - enriched.RemoteIp, + enriched.StatusCode, + enriched.StatusCategory, enriched.Host, enriched.Path, enriched.Referrer, diff --git a/cmd/query/cmd.go b/cmd/query/cmd.go index 75dd291..1f2b51f 100644 --- a/cmd/query/cmd.go +++ b/cmd/query/cmd.go @@ -113,7 +113,7 @@ var QueryCmd = &cobra.Command{ r.Use(cors.Handler(corsOptions)) r.Use(jwtauth.Verifier(jwtSecret)) r.Use(util.CheckJwtMiddleware((config["PERMISSIVE_MODE"] == "true"), false)) - r.Use(util.CheckHostnameMiddleware(config["PERMISSIVE_MODE"] == "true")) + r.Use(util.CheckZoneIdMiddleware(config["PERMISSIVE_MODE"] == "true")) r.Use(promHttpStd.HandlerProvider("", promMiddleware)) r.Get("/query", q.HandleQuery) diff --git a/cmd/query/influx.go b/cmd/query/influx.go index b4464ab..d4e2aea 100644 --- a/cmd/query/influx.go +++ b/cmd/query/influx.go @@ -1,13 +1,13 @@ package query import ( + "encoding/json" "fmt" "log" "net/http" "strconv" "strings" "time" - "encoding/json" ch "github.com/ClickHouse/clickhouse-go/v2/lib/driver" "golang.org/x/exp/slices" @@ -25,7 +25,7 @@ type QueryResult struct { } type Point struct { - Time int64 `json:"Time"` + Time int64 `json:"Time"` Hits uint64 `json:"Hits"` Bytes uint64 `json:"Bytes"` } @@ -39,9 +39,9 @@ func (q Query) HandleQuery(out http.ResponseWriter, req *http.Request) { // todo, this is gross. there must be a better way of defining and validating API spec - hostname := req.URL.Query().Get("hostname") - if hostname == "" { - http.Error(out, "Query param 'hostname' not provided, quitting", http.StatusBadRequest) + zoneId := req.URL.Query().Get("zoneid") + if zoneId == "" { + http.Error(out, "Query param 'zoneid' not provided, quitting", http.StatusBadRequest) return } @@ -93,7 +93,7 @@ func (q Query) HandleQuery(out http.ResponseWriter, req *http.Request) { return } - queryStr := BuildClickhouseQuery(hostname, includeBots, groupby, bucketby, timezone, unixStart, unixEnd) + queryStr := BuildClickhouseQuery(zoneId, includeBots, groupby, bucketby, timezone, unixStart, unixEnd) // if err != nil { // http.Error(out, fmt.Sprintf("Unable to create valid query for influxdb: %w", err), http.StatusBadRequest) // return @@ -124,7 +124,7 @@ func (q Query) HandleQuery(out http.ResponseWriter, req *http.Request) { return } -func BuildClickhouseQuery(hostname, includeBots, groupby, bucketby, timezone string, unixStart, unixEnd int) string { +func BuildClickhouseQuery(zoneId, includeBots, groupby, bucketby, timezone string, unixStart, unixEnd int) string { var query strings.Builder @@ -150,7 +150,7 @@ func BuildClickhouseQuery(hostname, includeBots, groupby, bucketby, timezone str query.WriteString("FROM accesslog ") - query.WriteString(fmt.Sprintf("WHERE Host = '%s' ", hostname)) + query.WriteString(fmt.Sprintf("WHERE PullZoneId = '%s' ", zoneId)) // the toDateTime might not be necessary here since we're supplying epoch ms, but shrug query.WriteString(fmt.Sprintf("AND Timestamp >= toDateTime(%d, '%s') ", unixStart, timezone)) diff --git a/util/auth.go b/util/auth.go index b6305bb..6555c7c 100644 --- a/util/auth.go +++ b/util/auth.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "net/http" + "strconv" "github.com/carlmjohnson/requests" "github.com/go-chi/jwtauth/v5" @@ -106,9 +107,9 @@ func CheckJwtMiddleware(permissive, basic bool) func(next http.Handler) http.Han } } -// gets the desired hostname from the query params, then checks the JWT metadata -// to make sure the user is allowed to query that hostname -func CheckHostnameMiddleware(permissive bool) func(next http.Handler) http.Handler { +// gets the desired pull zone ID from the query params, then checks the JWT metadata +// to make sure the user is allowed to query that zone +func CheckZoneIdMiddleware(permissive bool) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(out http.ResponseWriter, req *http.Request) { @@ -121,25 +122,31 @@ func CheckHostnameMiddleware(permissive bool) func(next http.Handler) http.Handl // already checked for errors etc in the previous CheckJwtMiddleware _, claims, _ := jwtauth.FromContext(req.Context()) - hostname := req.URL.Query().Get("hostname") - if hostname == "" { - http.Error(out, "Query param 'hostname' not provided, quitting", http.StatusBadRequest) + zoneId := req.URL.Query().Get("zoneid") + if zoneId == "" { + http.Error(out, "Query param 'zoneid' not provided, quitting", http.StatusBadRequest) return } - existingHostnames, err := GetHostnamesFromClaims(claims) + zoneIdInt, err := strconv.Atoi(zoneId) if err != nil { - http.Error(out, fmt.Sprintf("Unable to get hostnames from JWT claims: %v", err), http.StatusBadRequest) + http.Error(out, "Query param 'zoneid' could not be parsed as int, quitting", http.StatusBadRequest) return } - if !slices.Contains(existingHostnames, hostname) { - http.Error(out, fmt.Sprintf("User not authorized to query hostname %v", hostname), http.StatusUnauthorized) + existingZoneIds, err := GetZoneIdsFromClaims(claims) + if err != nil { + http.Error(out, fmt.Sprintf("Unable to get zone IDs from JWT claims: %v", err), http.StatusBadRequest) + return + } + + if !slices.Contains(existingZoneIds, zoneIdInt) { + http.Error(out, fmt.Sprintf("User not authorized to query zone ID %v", zoneIdInt), http.StatusUnauthorized) return } } - // user is allowed to query this hostname, pass it through + // user is allowed to query this pull zone, pass it through next.ServeHTTP(out, req) return }) @@ -173,7 +180,7 @@ func CheckReadOnlyMiddleware(permissive bool) func(next http.Handler) http.Handl } } - // user is allowed to query this hostname, pass it through + // user account is not readonly, pass it through next.ServeHTTP(out, req) return }) diff --git a/util/claims.go b/util/claims.go index a327ade..cbf8013 100644 --- a/util/claims.go +++ b/util/claims.go @@ -2,11 +2,12 @@ package util import ( "fmt" + "math" ) // lots of parsing and checking in order to get the list of authorized -// hostnames from a claims dict in a JWT, that's this -func GetHostnamesFromClaims(claims map[string]interface{}) ([]string, error) { +// pull zone IDs from a claims dict in a JWT, that's this +func GetZoneIdsFromClaims(claims map[string]interface{}) ([]int, error) { metadata, found1 := claims["app_metadata"] if !found1 { @@ -18,27 +19,29 @@ func GetHostnamesFromClaims(claims map[string]interface{}) ([]string, error) { return nil, fmt.Errorf("Claims 'app_metadata' could not be parsed as map") } - hostnames, found2 := metadataMap["hostnames"] + zones, found2 := metadataMap["zones"] if !found2 { - // return early with no error -- just means they've created no sites yet + // return early with no error -- just means they've created no zones yet return nil, nil } - hostnamesArray, ok2 := hostnames.([]interface{}) + zonesArray, ok2 := zones.([]interface{}) if !ok2 { - return nil, fmt.Errorf("Metadata 'hostnames' field could not be parsed as array") + return nil, fmt.Errorf("Metadata 'zones' field could not be parsed as array") } - hostnamesStringArray := []string{} - for _, name := range hostnamesArray { - nameString, ok3 := name.(string) + zoneIntArray := []int{} + for _, zone := range zonesArray { + // not sure why this comes in in float-form? It's just an int + zoneFloat, ok3 := zone.(float64) if !ok3 { - return nil, fmt.Errorf("Item in metadata 'hostnames' array could not be parsed as string") + return nil, fmt.Errorf("Item in metadata 'zones' array could not be parsed") } - hostnamesStringArray = append(hostnamesStringArray, nameString) + zoneInt := int(math.Round(zoneFloat)) + zoneIntArray = append(zoneIntArray, zoneInt) } - return hostnamesStringArray, nil + return zoneIntArray, nil } // similar to the above, but gets the straightforward "readonly" field diff --git a/util/random.go b/util/random.go index 48128d5..b5a5746 100644 --- a/util/random.go +++ b/util/random.go @@ -1,18 +1,29 @@ package util import ( + "fmt" "math/rand" ) -// shamelessly adapted from https://stackoverflow.com/questions/71486991 -func RandomString(length int) string { +func RandomConsonant() rune { + return []rune("bcdfghjklmnpqrstvwxz")[rand.Intn(20)] +} - res := make([]rune, length) - alphabet := []rune("abcdefghijklmnopqrstuvwxyz") +func RandomVowel() rune { + return []rune("aeiouy")[rand.Intn(6)] +} - for i := 0; i < length; i++ { - res[i] = alphabet[rand.Intn(26)] - } +func RandomIam() string { + c1 := string(RandomConsonant()) + v1 := string(RandomVowel()) + c2 := string(RandomConsonant()) + v2 := string(RandomVowel()) + return fmt.Sprintf("%s%s%s%s", c1, v1, c2, v2) +} - return string(res) +func RandomIamTriple() string { + i1 := RandomIam() + i2 := RandomIam() + i3 := RandomIam() + return fmt.Sprintf("%s-%s-%s", i1, i2, i3) }