Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
42 changes: 42 additions & 0 deletions cli/cmd/awsenv_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cmd

import (
"io"
"os"
"strings"
"testing"

"github.com/homecloudhq/homecloud/cli/internal/client"
)

// aws-env names the local server "localhost", not 127.0.0.1: S3 Control
// (which the Terraform AWS provider calls for every bucket) puts the account
// ID in front of the endpoint's host, which only works with a host name.
func TestAWSEnvUsesLocalhost(t *testing.T) {
t.Setenv("HOMECLOUD_DATA_DIR", t.TempDir())
t.Setenv("HOMECLOUD_ENDPOINT", "")
if err := client.Save(client.Profile{Endpoint: "http://127.0.0.1:18080", AccessKeyID: "AK", SecretAccessKey: "SK"}); err != nil {
t.Fatal(err)
}
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
stdout := os.Stdout
os.Stdout = w
RootCmd.SetArgs([]string{"aws-env"})
runErr := RootCmd.Execute()
os.Stdout = stdout
w.Close()
out, _ := io.ReadAll(r)
if runErr != nil {
t.Fatal(runErr)
}
if !strings.Contains(string(out), `export AWS_ENDPOINT_URL="http://localhost:18080"`) || !strings.Contains(string(out), `export AWS_ACCESS_KEY_ID="AK"`) {
t.Fatalf("aws-env printed:\n%s", out)
}
// The CLI itself keeps talking to the recorded address.
if p, _ := client.Load(); p.Endpoint != "http://127.0.0.1:18080" {
t.Fatalf("credentials endpoint changed: %s", p.Endpoint)
}
}
2 changes: 1 addition & 1 deletion cli/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ credentials, so AWS tools work against HomeCloud:
if region == "" || region == core.LegacyRegion {
region = core.DefaultRegion
}
vars := [][2]string{{"AWS_ENDPOINT_URL", p.Endpoint}, {"AWS_ACCESS_KEY_ID", p.AccessKeyID},
vars := [][2]string{{"AWS_ENDPOINT_URL", client.AWSEndpoint(p.Endpoint)}, {"AWS_ACCESS_KEY_ID", p.AccessKeyID},
{"AWS_SECRET_ACCESS_KEY", p.SecretAccessKey}, {"AWS_REGION", region}}
if p.CAFile != "" {
vars = append(vars, [2]string{"AWS_CA_BUNDLE", p.CAFile})
Expand Down
3 changes: 3 additions & 0 deletions cli/internal/awsapi/awstest/awstest.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ func (h *Harness) Signed(t *testing.T, service, method, path string, header http
r.Header.Set("X-Amz-Content-Sha256", hash)
r.Header.Set("Host", r.URL.Host)
signed := []string{"host", "x-amz-content-sha256", "x-amz-date"}
if r.Header.Get("X-Amz-Target") != "" { // awsJson protocols must sign the target
signed = append(signed, "x-amz-target")
}
cred := h.AccessKeyID + "/" + now.Format("20060102") + "/us-east-1/" + service + "/aws4_request"
r.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+cred+", SignedHeaders="+strings.Join(signed, ";")+", Signature=0")
sig, err := awsapi.ParseSignature(r)
Expand Down
24 changes: 24 additions & 0 deletions cli/internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -83,6 +84,29 @@ func Load() (Profile, error) {
return p, nil
}

// AWSEndpoint is the endpoint to give AWS tools (AWS_ENDPOINT_URL): the API
// endpoint with a loopback IP address replaced by "localhost". Path-style S3
// works with either, but S3 Control puts the account ID in front of the host
// (http://123456789012.localhost:8080/v20180820/...), and the Terraform AWS
// provider calls it for every aws_s3_bucket (6.x reads bucket tags that way),
// as do `aws s3control` and boto3's s3control client. 123456789012.127.0.0.1
// does not resolve; *.localhost resolves to loopback.
func AWSEndpoint(endpoint string) string {
u, err := url.Parse(endpoint)
if err != nil || u.Host == "" {
return endpoint
}
if h := u.Hostname(); h != "127.0.0.1" && h != "::1" {
return endpoint
}
if port := u.Port(); port != "" {
u.Host = net.JoinHostPort("localhost", port)
} else {
u.Host = "localhost"
}
return u.String()
}

func Save(p Profile) error {
if err := os.MkdirAll(filepath.Dir(CredentialsFile()), 0o700); err != nil {
return err
Expand Down
23 changes: 23 additions & 0 deletions cli/internal/client/endpoint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package client

import "testing"

func TestAWSEndpoint(t *testing.T) {
for in, want := range map[string]string{
"http://127.0.0.1:8080": "http://localhost:8080",
"http://127.0.0.1:18080/": "http://localhost:18080/",
"https://127.0.0.1:8443": "https://localhost:8443",
"http://[::1]:8080": "http://localhost:8080",
"http://127.0.0.1": "http://localhost",
"http://localhost:8080": "http://localhost:8080",
"http://192.168.1.20:8080": "http://192.168.1.20:8080", // another machine: keep its address
"http://127.0.0.2:8080": "http://127.0.0.2:8080", // localhost would not reach it
"https://cloud.example.com": "https://cloud.example.com",
"http://homelab:8080": "http://homelab:8080",
"": "",
} {
if got := AWSEndpoint(in); got != want {
t.Errorf("AWSEndpoint(%q) = %q, want %q", in, got, want)
}
}
}
42 changes: 31 additions & 11 deletions cli/internal/svc/cognito/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,25 +192,37 @@ func mergeExtras(dst map[string]any, in map[string]any) map[string]any {
return dst
}

// awsPolicy reads Policies.PasswordPolicy; AWS requires every character class
// unless the policy turns it off.
func awsPolicy(in map[string]any, base PasswordPolicy) PasswordPolicy {
// awsPolicy reads Policies.PasswordPolicy. Without one, the pool keeps base
// (for a new pool AWS's default: every character class). A given policy is
// taken as a whole: a character class it leaves out is not required, as in
// AWS, where the Go SDK v2 (and so the Terraform provider) omits false flags
// from the request. MinimumLength must be 6-99.
func awsPolicy(in map[string]any, base PasswordPolicy) (PasswordPolicy, error) {
pol, _ := in["Policies"].(map[string]any)
pp, _ := pol["PasswordPolicy"].(map[string]any)
if pp == nil {
return base
}
out := PasswordPolicy{MinLength: 8, RequireUppercase: true, RequireLowercase: true, RequireNumbers: true, RequireSymbols: true}
if n, ok := intOf(pp, "MinimumLength"); ok {
out.MinLength = n
return base, nil
}
out := PasswordPolicy{MinLength: 8}
if v, present := pp["MinimumLength"]; present && v != nil {
f, ok := v.(float64)
switch {
case !ok || f != float64(int(f)):
return out, invalid("1 validation error detected: Value '%v' at 'policies.passwordPolicy.minimumLength' failed to satisfy constraint: Member must be an integer", v)
case f < minPasswordLength:
return out, invalid("1 validation error detected: Value '%d' at 'policies.passwordPolicy.minimumLength' failed to satisfy constraint: Member must have value greater than or equal to %d", int(f), minPasswordLength)
case f > maxPasswordLength:
return out, invalid("1 validation error detected: Value '%d' at 'policies.passwordPolicy.minimumLength' failed to satisfy constraint: Member must have value less than or equal to %d", int(f), maxPasswordLength)
}
out.MinLength = int(f)
}
for k, dst := range map[string]*bool{"RequireUppercase": &out.RequireUppercase, "RequireLowercase": &out.RequireLowercase,
"RequireNumbers": &out.RequireNumbers, "RequireSymbols": &out.RequireSymbols} {
if v, ok := flagOf(pp, k); ok {
*dst = v
}
}
return out
return out, nil
}

func adminOnly(in map[string]any) (v, ok bool) {
Expand All @@ -227,7 +239,11 @@ func (s *Service) awsCreateUserPool(q *awsapi.Req) (any, error) {
return nil, err
}
// Users sign up with their e-mail address as username when UsernameAttributes says so.
p, err := s.newPool(str(in, "PoolName"), awsPolicy(in, PasswordPolicy{MinLength: 8, RequireUppercase: true, RequireLowercase: true, RequireNumbers: true, RequireSymbols: true}),
pp, err := awsPolicy(in, PasswordPolicy{MinLength: 8, RequireUppercase: true, RequireLowercase: true, RequireNumbers: true, RequireSymbols: true})
if err != nil {
return nil, err
}
p, err := s.newPool(str(in, "PoolName"), pp,
true, func() bool { v, ok := adminOnly(in); return !(ok && v) }(), tagsOf(in["UserPoolTags"]), mergeExtras(nil, in))
if err != nil {
return nil, err
Expand Down Expand Up @@ -386,7 +402,11 @@ func (s *Service) awsUpdateUserPool(q *awsapi.Req) (any, error) {
return nil, err
}
_, err := s.modPool(id, func(p *Pool) error {
p.PasswordPolicy = awsPolicy(in, p.PasswordPolicy)
pp, err := awsPolicy(in, p.PasswordPolicy)
if err != nil {
return err
}
p.PasswordPolicy = pp
if err := checkPolicy(&p.PasswordPolicy); err != nil {
return err
}
Expand Down
14 changes: 11 additions & 3 deletions cli/internal/svc/cognito/cognito.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,13 +415,21 @@ func (s *Service) createPool(c *httpx.Ctx) (any, error) {
return s.poolView(p), nil
}

// checkPolicy validates and normalizes a password policy.
// Bounds of a password policy's minimum length, as AWS enforces them on
// PasswordPolicyType.MinimumLength.
const (
minPasswordLength = 6
maxPasswordLength = 99
)

// checkPolicy validates and normalizes a password policy. A zero minimum length
// means "unset" on the native API and takes the default of 8.
func checkPolicy(pp *PasswordPolicy) error {
if pp.MinLength == 0 {
pp.MinLength = 8
}
if pp.MinLength < 6 || pp.MinLength > 99 {
return core.BadRequest("password_policy.min_length must be 6-99")
if pp.MinLength < minPasswordLength || pp.MinLength > maxPasswordLength {
return core.BadRequest("password_policy.min_length must be %d-%d", minPasswordLength, maxPasswordLength)
}
return nil
}
Expand Down
85 changes: 85 additions & 0 deletions cli/internal/svc/cognito/policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package cognito_test

import (
"encoding/json"
"net/http"
"strings"
"testing"

"github.com/homecloudhq/homecloud/cli/internal/awsapi/awstest"
)

// signedCall sends a SigV4-signed cognito-idp request straight to the server,
// skipping the client-side parameter validation the AWS CLI and boto3 do, so
// out-of-range values reach the service as they would from any SDK.
func signedCall(t *testing.T, h *awstest.Harness, op string, body any) (int, map[string]any) {
t.Helper()
b, _ := json.Marshal(body)
hdr := http.Header{}
hdr.Set("X-Amz-Target", "AWSCognitoIdentityProviderService."+op)
hdr.Set("Content-Type", "application/x-amz-json-1.1")
st, _, raw := h.Signed(t, "cognito-idp", "POST", "/", hdr, b)
out := map[string]any{}
_ = json.Unmarshal(raw, &out)
return st, out
}

func policy(n any) map[string]any {
return map[string]any{"PasswordPolicy": map[string]any{"MinimumLength": n}}
}

// AWS accepts a PasswordPolicy MinimumLength of 6-99; anything else is an
// InvalidParameterException, on create and on update, over both APIs.
func TestPasswordPolicyMinimumLengthBounds(t *testing.T) {
h, _ := newCognito(t)
for _, n := range []any{6, 99} {
st, out := signedCall(t, h, "CreateUserPool", map[string]any{"PoolName": "ok", "Policies": policy(n)})
if st != 200 || sub(out, "UserPool", "Policies", "PasswordPolicy")["MinimumLength"] != float64(n.(int)) {
t.Fatalf("MinimumLength %v: %d %v", n, st, out)
}
}
for _, n := range []any{0, 5, 100, 128, -1, 8.5, "8"} {
st, out := signedCall(t, h, "CreateUserPool", map[string]any{"PoolName": "bad", "Policies": policy(n)})
if st != 400 || out["__type"] != "InvalidParameterException" || !strings.Contains(out["message"].(string), "minimumLength") {
t.Fatalf("create MinimumLength %v: %d %v", n, st, out)
}
}

pool := sub(h.AWSJSON(t, "cognito-idp", "create-user-pool", "--pool-name", "upd"), "UserPool")["Id"].(string)
st, out := signedCall(t, h, "UpdateUserPool", map[string]any{"UserPoolId": pool, "Policies": policy(100)})
if st != 400 || out["__type"] != "InvalidParameterException" {
t.Fatalf("update MinimumLength 100: %d %v", st, out)
}
pp := sub(h.AWSJSON(t, "cognito-idp", "describe-user-pool", "--user-pool-id", pool), "UserPool", "Policies", "PasswordPolicy")
if pp["MinimumLength"] != float64(8) {
t.Fatalf("a rejected update changed the policy: %v", pp)
}
if o, err := h.AWSErr(t, "cognito-idp", "update-user-pool", "--user-pool-id", pool, "--policies", "PasswordPolicy={MinimumLength=100}"); err == nil || !strings.Contains(o, "InvalidParameterException") {
t.Fatalf("aws cli update MinimumLength 100: %v %s", err, o)
}

// A given policy is taken as a whole: the Go SDK v2 leaves false flags out
// of the request, so a missing character class is not required.
st, out = signedCall(t, h, "UpdateUserPool", map[string]any{"UserPoolId": pool, "Policies": map[string]any{"PasswordPolicy": map[string]any{"MinimumLength": 12, "RequireNumbers": true}}})
if st != 200 {
t.Fatalf("update: %d %v", st, out)
}
pp = sub(h.AWSJSON(t, "cognito-idp", "describe-user-pool", "--user-pool-id", pool), "UserPool", "Policies", "PasswordPolicy")
if pp["MinimumLength"] != float64(12) || pp["RequireNumbers"] != true || pp["RequireUppercase"] != false || pp["RequireLowercase"] != false || pp["RequireSymbols"] != false {
t.Fatalf("partial policy: %v", pp)
}
// Without a policy a new pool requires every character class.
pp = sub(h.AWSJSON(t, "cognito-idp", "create-user-pool", "--pool-name", "dflt"), "UserPool", "Policies", "PasswordPolicy")
if pp["MinimumLength"] != float64(8) || pp["RequireUppercase"] != true || pp["RequireSymbols"] != true {
t.Fatalf("default policy: %v", pp)
}

// The native API enforces the same bounds.
for n, want := range map[int]int{6: 200, 99: 200, 5: 400, 100: 400, 128: 400} {
st, body := h.NativeAs(t, h.AccessKeyID, h.SecretKey, "POST", "/api/v1/cognito/user-pools",
map[string]any{"name": "native", "password_policy": map[string]any{"min_length": n}})
if st/100 != want/100 {
t.Fatalf("native min_length %d: %d %s", n, st, body)
}
}
}
3 changes: 1 addition & 2 deletions compat/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,8 @@ start_server() {
done
grep -q "s3: MinIO ready" "$WORK/serve.log" || { cat "$WORK/serve.log"; echo "S3 not ready" >&2; exit 2; }
ACCOUNT=$(sed -n 's/.*created account \([0-9]*\).*/\1/p' "$WORK/serve.log" | head -1)
# aws-env names the server localhost: S3 Control puts the account ID in the host name.
eval "$(HOMECLOUD_DATA_DIR=$DATA "$bin" aws-env)"
# The S3 client puts the account in the host name: use a name, not an IP address.
export AWS_ENDPOINT_URL=http://localhost:$api
export HC_DNS_PORT=$dns
echo "HomeCloud $AWS_ENDPOINT_URL (account $ACCOUNT, data $DATA)"
}
Expand Down
15 changes: 10 additions & 5 deletions console/components/cognito/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ export function SignUpBadge({ pool }: { pool: Pick<UserPool, "self_sign_up" | "a
)
}

/** PasswordPolicyFields edits a password policy (min length 6-128 and the four character classes). */
/** Bounds of a password policy's minimum length (AWS PasswordPolicyType.MinimumLength). */
export const MIN_PW_LENGTH = 6
export const MAX_PW_LENGTH = 99

/** PasswordPolicyFields edits a password policy (min length 6-99, as in AWS, and the four character classes). */
export function PasswordPolicyFields({ value, onChange, idPrefix, error }: { value: PasswordPolicy; onChange: (p: PasswordPolicy) => void; idPrefix: string; error?: string }) {
const boxes: [keyof PasswordPolicy, string][] = [
["require_uppercase", "Uppercase letters"],
Expand All @@ -76,12 +80,12 @@ export function PasswordPolicyFields({ value, onChange, idPrefix, error }: { val
]
return (
<div className="flex flex-col gap-3">
<Field label="Minimum length" htmlFor={`${idPrefix}-min`} error={error} help="6-128 characters.">
<Field label="Minimum length" htmlFor={`${idPrefix}-min`} error={error} help={`${MIN_PW_LENGTH}-${MAX_PW_LENGTH} characters.`}>
<Input
id={`${idPrefix}-min`}
type="number"
min={6}
max={128}
min={MIN_PW_LENGTH}
max={MAX_PW_LENGTH}
value={Number.isNaN(value.min_length) ? "" : value.min_length}
onChange={(e) => onChange({ ...value, min_length: e.target.value === "" ? NaN : Number(e.target.value) })}
className="w-28"
Expand All @@ -101,7 +105,8 @@ export function PasswordPolicyFields({ value, onChange, idPrefix, error }: { val
)
}

export const minLengthError = (n: number) => (!Number.isInteger(n) || n < 6 || n > 99 ? "Enter a whole number from 6 to 99" : undefined)
export const minLengthError = (n: number) =>
!Number.isInteger(n) || n < MIN_PW_LENGTH || n > MAX_PW_LENGTH ? `Enter a whole number from ${MIN_PW_LENGTH} to ${MAX_PW_LENGTH}` : undefined

/** SwitchRow is a labelled switch with a description (settings toggles). */
export function SwitchRow({ id, label, description, checked, onChange, disabled }: { id: string; label: string; description: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
Expand Down
12 changes: 3 additions & 9 deletions console/components/home/console-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { StatTile } from "@/components/console/stat-tile"
import { StatusDot } from "@/components/console/status-badge"
import { TimeAgo } from "@/components/console/time-ago"
import { DOCS_URL, QUICK_ACTIONS, REPO_URL } from "@/lib/actions"
import { API_BASE, DEMO } from "@/lib/api"
import { awsEndpoint } from "@/lib/api"
import { formatDuration, pluralize } from "@/lib/format"
import { useApi } from "@/lib/hooks"
import { SERVICES } from "@/lib/services"
Expand Down Expand Up @@ -78,12 +78,6 @@ interface ServiceCount {
counts?: boolean
}

function endpoint(): string {
if (DEMO) return "http://127.0.0.1:8080"
if (API_BASE) return API_BASE
return typeof window === "undefined" ? "" : window.location.origin
}

function greeting(): string {
const h = new Date().getHours()
return h < 5 ? "Good evening" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening"
Expand Down Expand Up @@ -440,7 +434,7 @@ function Panel({ title, action, children, className }: { title: ReactNode; actio
}

function ConnectCli({ compact }: { compact?: boolean }) {
const ep = endpoint()
const ep = awsEndpoint()
return (
<Panel
title="Connect the AWS CLI"
Expand Down Expand Up @@ -479,7 +473,7 @@ function Step({ n, title, children, aside }: { n: number; title: ReactNode; chil

/** GetStarted replaces the dashboard while the account has no resources. */
function GetStarted() {
const ep = endpoint()
const ep = awsEndpoint()
return (
<div className="flex flex-col gap-6">
<section className="hc-backdrop bg-card overflow-hidden rounded-2xl border px-6 py-8 shadow-xs sm:px-10 sm:py-10">
Expand Down
Loading
Loading