carlos / platform Public

Merge remote-tracking branch 'origin/main' into moves-phase1-5b

claude agent, operated by paul@keymail.dev 3206bb9899a587e77021205190dd3555b5a8aaa5 parent c4450fcparent 7561848
55 files changed, +2374 −83
  • cmd/console/main.go +2 −0
  • internal/alerting/alerting.go +2 −1
  • internal/awsinv/awsinv.go +2 −2
  • internal/awsinv/exact_instance.go +2 −1
  • internal/console/cepull.go +2 −2
  • internal/dnsrec/route53.go +2 −1
  • internal/edge/route53dns.go +2 −1
  • internal/host/agent.go +6 −0
  • internal/host/bootstrap.go +9 −24
  • internal/host/claimproduction_marker.go +2 −1
  • internal/host/claimroleassembly_linux.go +1 −1
  • internal/host/claimroleassembly_linux_test.go +53 −0
  • internal/host/host_test.go +34 −10
  • internal/host/memwatch.go +2 −2
  • internal/host/offcloudcredentials_linux.go +131 −0
  • internal/host/offcloudcredentials_linux_test.go +251 −0
  • internal/host/offcloudcredentials_other.go +16 −0
  • internal/host/registrychild_exec_linux.go +71 −0
  • internal/host/registrychild_exec_linux_test.go +100 −0
  • internal/host/registrychild_exec_other.go +8 −0
  • internal/host/registrychild_unix.go +5 −3
  • internal/host/registrychild_unix_test.go +3 −3
  • internal/host/stagingcleanup.go +32 −0
  • internal/host/stagingcleanup_linux_test.go +74 −0
  • internal/host/systemapply.go +1 −0
  • internal/host/units/carlos-system-recover.service +1 −1
  • internal/host/units/carlos-system-update.service +1 −1
  • internal/lease/checkpoint.go +4 −1
  • internal/lease/claimgeneration.go +10 −2
  • internal/lease/claimgeneration_test.go +8 −2
  • internal/lease/endpoint.go +26 −5
  • internal/lease/endpoint_test.go +133 −2
  • internal/lease/lease.go +15 −5
  • internal/lease/retention_window_test.go +8 −2
  • internal/lease/staging.go +2 −1
  • internal/mail/aws.go +2 −1
  • internal/objstore/aws.go +3 −2
  • internal/provision/plan9_b8_census_test.go +2 −2
  • internal/registry/remote.go +1 −0
  • internal/release/store.go +4 −3
  • internal/rootaws/config.go +100 −0
  • internal/rootaws/config_test.go +297 −0
  • internal/rootaws/credentials.go +71 −0
  • internal/rootaws/credentials_linux_test.go +138 −0
  • internal/rootaws/credentials_other.go +13 −0
  • internal/rootaws/credentials_unix.go +31 −0
  • internal/rootaws/environment.go +97 −0
  • internal/rootaws/environment_test.go +151 −0
  • internal/rootaws/open_darwin.go +11 −0
  • internal/rootaws/open_linux.go +13 −0
  • internal/safefile/reap_linux.go +179 −0
  • internal/safefile/reap_linux_test.go +230 −0
  • internal/safefile/reap_other.go +6 −0
  • internal/steering/route53.go +2 −1
  • main.go +2 −0

CI passed — run details

diff --git a/cmd/console/main.go b/cmd/console/main.go
index f44d65d5c..ab1ef043d 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -99,11 +99,13 @@ import (
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootaws"
"github.com/carlosframework/platform/internal/status"
"github.com/carlosframework/platform/internal/steering"
)
func main() {
+ rootaws.ScrubEnvironment()
log.SetFlags(0)
if len(os.Args) < 2 {
usage()
diff --git a/internal/alerting/alerting.go b/internal/alerting/alerting.go
index 782cc052b..8956e2fec 100644
--- a/internal/alerting/alerting.go
+++ b/internal/alerting/alerting.go
@@ -26,6 +26,7 @@ import (
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/sns"
snstypes "github.com/aws/aws-sdk-go-v2/service/sns/types"
+ "github.com/carlosframework/platform/internal/rootaws"
)
// Page levels (moves spec §13): an incident wants someone now, a warning
@@ -237,7 +238,7 @@ func ParseTopicARN(s string) (region string, err error) {
// (a box's instance role, the console's task role), aimed at the topic's
// own region.
func NewSNSClient(ctx context.Context, region string) (Publisher, error) {
- cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
+ cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
if err != nil {
return nil, err
}
diff --git a/internal/awsinv/awsinv.go b/internal/awsinv/awsinv.go
index ab25d647c..20492fbd2 100644
--- a/internal/awsinv/awsinv.go
+++ b/internal/awsinv/awsinv.go
@@ -7,11 +7,11 @@ import (
"strings"
"time"
- awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ec2"
"github.com/aws/aws-sdk-go-v2/service/sts"
"github.com/aws/smithy-go"
"github.com/carlosframework/platform/internal/infraview"
+ "github.com/carlosframework/platform/internal/rootaws"
)
// credentialProbe is how long New waits for the credential chain to answer
@@ -49,7 +49,7 @@ type Provider struct {
// has no inventory grant", latching the pass off rather than retrying
// hourly forever.
func New(ctx context.Context) (*Provider, error) {
- cfg, err := awsconfig.LoadDefaultConfig(ctx)
+ cfg, err := rootaws.LoadDefaultConfig(ctx)
if err != nil {
return nil, fmt.Errorf("awsinv: aws config: %w", err)
}
diff --git a/internal/awsinv/exact_instance.go b/internal/awsinv/exact_instance.go
index ff854f0f0..61a725c46 100644
--- a/internal/awsinv/exact_instance.go
+++ b/internal/awsinv/exact_instance.go
@@ -10,6 +10,7 @@ import (
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ec2"
+ "github.com/carlosframework/platform/internal/rootaws"
)
// ErrExactInstance marks every result that fails to prove that the exact
@@ -42,7 +43,7 @@ func NewEC2Reader(ctx context.Context, region string) (ExactEC2API, error) {
if strings.TrimSpace(region) == "" {
return nil, fmt.Errorf("%w: enrolled region is empty", ErrExactInstance)
}
- cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
+ cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
if err != nil {
return nil, fmt.Errorf("%w: AWS config: %v", ErrExactInstance, err)
}
diff --git a/internal/console/cepull.go b/internal/console/cepull.go
index 52e5510d4..52c4c08b1 100644
--- a/internal/console/cepull.go
+++ b/internal/console/cepull.go
@@ -27,10 +27,10 @@ import (
"time"
"github.com/aws/aws-sdk-go-v2/aws"
- awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/costexplorer"
cetypes "github.com/aws/aws-sdk-go-v2/service/costexplorer/types"
"github.com/aws/smithy-go"
+ "github.com/carlosframework/platform/internal/rootaws"
)
// resolveCECosts is billPass's entry point into the seam: the test/fake
@@ -55,7 +55,7 @@ func (s *Server) realCECosts(ctx context.Context, start, end time.Time) (map[str
return nil, fmt.Errorf("console: cost explorer disabled for this process")
}
s.ceClientOnce.Do(func() {
- cfg, err := awsconfig.LoadDefaultConfig(ctx)
+ cfg, err := rootaws.LoadDefaultConfig(ctx)
if err != nil {
// The AWS config itself failed to resolve — opaque and
// permanent for this process: there is no "retry the SDK's
diff --git a/internal/dnsrec/route53.go b/internal/dnsrec/route53.go
index 96ab91d5f..915a93fca 100644
--- a/internal/dnsrec/route53.go
+++ b/internal/dnsrec/route53.go
@@ -18,6 +18,7 @@ import (
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/route53"
r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
+ "github.com/carlosframework/platform/internal/rootaws"
)
type route53Writer struct {
@@ -33,7 +34,7 @@ func NewRoute53Writer(ctx context.Context) (Writer, error) {
if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" {
opts = append(opts, awsconfig.WithRegion("us-east-1"))
}
- cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
+ cfg, err := rootaws.LoadDefaultConfig(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("dnsrec: load aws config: %w", err)
}
diff --git a/internal/edge/route53dns.go b/internal/edge/route53dns.go
index 66f9324e2..c7ad6fe8b 100644
--- a/internal/edge/route53dns.go
+++ b/internal/edge/route53dns.go
@@ -26,6 +26,7 @@ import (
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/route53"
r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
+ "github.com/carlosframework/platform/internal/rootaws"
)
// route53InSyncTimeout bounds the wait for a ChangeResourceRecordSets to
@@ -57,7 +58,7 @@ func newRoute53DNS(ctx context.Context) (dnsProvider, error) {
if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" {
opts = append(opts, awsconfig.WithRegion("us-east-1"))
}
- cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
+ cfg, err := rootaws.LoadDefaultConfig(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("route53: load aws config: %w", err)
}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 72d832150..d970f9dec 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -3925,6 +3925,12 @@ func runAdopt(args []string, buildClaims ClaimFactory) {
// runAdoptWithEnsureSystemUnits keeps the root-only unit effect injectable;
// the rest of adopt startup, including its fatal registry diagnostics, is real.
func runAdoptWithEnsureSystemUnits(args []string, buildClaims ClaimFactory, ensureUnits func()) {
+ reapStagingWrites(release.OptDir(), dataDir(), "/etc", log.Printf)
+ if os.Geteuid() == 0 {
+ if err := repairOffcloudCredentials("/etc/carlos/credentials"); err != nil {
+ log.Printf("ALERT: adopt: offcloud credentials: %v", err)
+ }
+ }
claimCtx, err := claimEntryContext(context.Background(), os.Getenv, openDeployStore)
if err != nil {
log.Fatalf("adopt: host claims: %v", err)
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go
index 4ed7efbf8..86045311d 100644
--- a/internal/host/bootstrap.go
+++ b/internal/host/bootstrap.go
@@ -329,27 +329,20 @@ func (f *repeatableFlag) Set(v string) error {
// wrong-region-fails-at-IAM holds off-cloud too. The flag is explicit; no
// IMDS sniffing (the agent's one IMDS probe is already gated for non-EC2).
//
-// The credential check runs BEFORE any system mutation: a failed precondition
-// leaves the box untouched, same as a failed bootstrap() would.
+// Credential shape and mode checks precede bootstrap mutations. A rejected
+// credential precondition leaves the box untouched.
func bootstrapOffcloud(root string, consumers []string) error {
cred := filepath.Join(root, "etc", "carlos", "credentials")
- fi, err := os.Stat(cred)
+ fi, err := checkOffcloudCredentials(cred)
if err != nil {
return fmt.Errorf("offcloud: stage the scoped credentials file first: %s (0600, see infra/remote-host.md): %w", cred, err)
}
- if fi.Mode().Perm() != 0o600 {
- return fmt.Errorf("offcloud: %s must be mode 0600, is %o", cred, fi.Mode().Perm())
+ if fi.Mode().Perm() != 0o600 && fi.Mode().Perm() != 0o640 {
+ return fmt.Errorf("offcloud: %s must be mode 0600 or 0640, is %o", cred, fi.Mode().Perm())
}
if err := bootstrap(root, consumers); err != nil {
return err
}
- // Ownership is a live-box-only step, same as ensureServiceUser/chownToService
- // in bootstrap() above — a staging root has no service user to chown to.
- if filepath.Clean(root) == "/" {
- if err := chownToService(cred); err != nil { // same helper the data dirs use
- return err
- }
- }
fmt.Printf("offcloud bootstrap complete.\n ensure /etc/carlos/host.env sets:\n AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials\n then stage /opt/carlos/carlos and start carlos-edge.service (see infra/remote-host.md)\n")
return nil
}
@@ -601,6 +594,10 @@ func bootstrapWith(root string, consumers []string, planJSON bool) error {
return nil
}
+ // Repair before arming any root pass that may load these credentials.
+ if err := repairOffcloudCredentials("/etc/carlos/credentials"); err != nil {
+ return err
+ }
for _, args := range systemctlPlan(enabledUnits) {
if err := systemctl(args...); err != nil {
return err
@@ -728,18 +725,6 @@ func bootstrapOwner(owner, group string) (int, int, error) {
return uid, gid, err
}
-func chownToService(path string) error {
- uid, gid, err := bootstrapOwner(serviceUser, serviceUser)
- if err != nil {
- return err
- }
- _, err = ownRegistryFile(path, uid, gid)
- if err != nil {
- log.Printf("ALERT: bootstrap: refusing ownership of %s: %v", path, err)
- }
- return err
-}
-
func systemctl(args ...string) error {
out, err := exec.Command("systemctl", args...).CombinedOutput()
if err != nil {
diff --git a/internal/host/claimproduction_marker.go b/internal/host/claimproduction_marker.go
index a88a6686d..76e34f185 100644
--- a/internal/host/claimproduction_marker.go
+++ b/internal/host/claimproduction_marker.go
@@ -10,6 +10,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/rootaws"
)
type productionClaimMarkers struct {
@@ -18,7 +19,7 @@ type productionClaimMarkers struct {
}
func newProductionClaimMarkers(ctx context.Context, bucket, region string) (ClaimMarkerStore, error) {
- cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region), awsconfig.WithRetryMaxAttempts(1))
+ cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region), awsconfig.WithRetryMaxAttempts(1))
if err != nil {
return nil, err
}
diff --git a/internal/host/claimroleassembly_linux.go b/internal/host/claimroleassembly_linux.go
index 0663c0817..ac9baee27 100644
--- a/internal/host/claimroleassembly_linux.go
+++ b/internal/host/claimroleassembly_linux.go
@@ -206,7 +206,7 @@ func verifiedClaimExecutable(ctx context.Context, cmd *exec.Cmd, path, digest st
if err = unix.Fstat(fd, &before); err != nil {
return refuse(err)
}
- if before.Mode&unix.S_IFMT != unix.S_IFREG || before.Nlink != 1 || before.Mode&0111 == 0 || before.Mode&0022 != 0 {
+ if before.Uid != 0 || before.Mode&unix.S_IFMT != unix.S_IFREG || before.Nlink != 1 || before.Mode&0111 == 0 || before.Mode&0022 != 0 {
return refuse(hostbox.ErrChanged)
}
hash := sha256.New()
diff --git a/internal/host/claimroleassembly_linux_test.go b/internal/host/claimroleassembly_linux_test.go
index 3c8d5b2fa..1bad58bed 100644
--- a/internal/host/claimroleassembly_linux_test.go
+++ b/internal/host/claimroleassembly_linux_test.go
@@ -11,9 +11,14 @@ import (
"os/exec"
"path/filepath"
"testing"
+
+ "golang.org/x/sys/unix"
)
func TestClaimExecutablePinsVerifiedBytes(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("requires root to create and replace a trusted executable")
+ }
path := filepath.Join(t.TempDir(), "binary")
body := []byte("approved executable bytes")
if err := os.WriteFile(path, body, 0555); err != nil {
@@ -40,6 +45,54 @@ func TestClaimExecutablePinsVerifiedBytes(t *testing.T) {
t.Fatal("changed bytes admitted")
}
}
+
+func TestClaimExecutableRefusesNonRootOwner(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "binary")
+ body := []byte("approved executable bytes")
+ if err := os.WriteFile(path, body, 0555); err != nil {
+ t.Fatal(err)
+ }
+ if os.Geteuid() == 0 {
+ if err := os.Chown(path, 65534, 65534); err != nil {
+ t.Fatal(err)
+ }
+ }
+ digest := sha256.Sum256(body)
+ if f, err := verifiedClaimExecutable(t.Context(), exec.Command(path), path, hex.EncodeToString(digest[:])); err == nil {
+ f.Close()
+ t.Fatal("non-root-owned executable accepted")
+ }
+}
+
+func TestClaimExecutableAcceptsRootOwnedSystemBinary(t *testing.T) {
+ path, err := filepath.EvalSymlinks("/usr/bin/true")
+ if err != nil {
+ t.Skipf("requires a root-owned system executable: %v", err)
+ }
+ var st unix.Stat_t
+ if err := unix.Stat(path, &st); err != nil || st.Uid != 0 || st.Nlink != 1 || st.Mode&0022 != 0 {
+ t.Skip("requires a root-owned single-link system executable")
+ }
+ body, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ digest := sha256.Sum256(body)
+ f, err := verifiedClaimExecutable(t.Context(), exec.Command(path), path, hex.EncodeToString(digest[:]))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ got, err := io.ReadAll(f)
+ if err != nil || string(got) != string(body) {
+ t.Fatalf("pinned system executable differs: %v", err)
+ }
+ digest[0] ^= 1
+ if f, err := verifiedClaimExecutable(t.Context(), exec.Command(path), path, hex.EncodeToString(digest[:])); err == nil {
+ f.Close()
+ t.Fatal("wrong system executable digest accepted")
+ }
+}
func TestClaimExecutableRefusesSymlinkAndWritableArtifact(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "binary")
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index 2313b98e3..c84c8f20a 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -1167,7 +1167,7 @@ func TestBootstrapPrivilegedPlanEnrollsConsumers(t *testing.T) {
// (issue #53 point 4): a box with no EC2 instance profile must have its scoped
// credentials staged — and staged at 0600 — before bootstrap proceeds. This is
// the staging-root path (no root privileges available in tests), so the
-// service-user chown below never runs; that only happens on a live root.
+// root:service ownership repair never runs; that only happens on a live root.
func TestBootstrapOffcloudRequiresCredentialsFile(t *testing.T) {
root := t.TempDir()
if err := bootstrapOffcloud(root, nil); err == nil {
@@ -1802,14 +1802,18 @@ func TestSystemUnitsSelectPrevByTheFloor(t *testing.T) {
floor, verdict string // "" means no file
noPrev bool
want string
+ floorKind string
}{
- {"no journal", false, "", "", false, "carlos"},
- {"no floor", true, "", "", false, "carlos.prev"},
- {"no floor, no prev", true, "", "", true, "carlos"},
- {"floor and a verdict naming it", true, "v0.30.0\n", "v0.30.0\n", false, "carlos.prev"},
- {"a verdict for an older floor", true, "v0.30.0\n", "v0.29.0\n", false, "carlos"},
- {"floor and no verdict", true, "v0.30.0\n", "", false, "carlos"},
- {"no journal, armed and allowed", false, "v0.30.0\n", "v0.30.0\n", false, "carlos"},
+ {"no journal", false, "", "", false, "carlos", ""},
+ {"no floor", true, "", "", false, "carlos.prev", ""},
+ {"no floor, no prev", true, "", "", true, "carlos", ""},
+ {"floor and a verdict naming it", true, "v0.30.0\n", "v0.30.0\n", false, "carlos.prev", ""},
+ {"a verdict for an older floor", true, "v0.30.0\n", "v0.29.0\n", false, "carlos", ""},
+ {"floor and no verdict", true, "v0.30.0\n", "", false, "carlos", ""},
+ {"no journal, armed and allowed", false, "v0.30.0\n", "v0.30.0\n", false, "carlos", ""},
+ {"symlink floor with matching verdict", true, "v0.30.0\n", "v0.30.0\n", false, "carlos", "symlink"},
+ {"dangling symlink floor", true, "", "", false, "carlos", "dangling"},
+ {"directory floor", true, "", "", false, "carlos", "directory"},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
@@ -1827,8 +1831,28 @@ func TestSystemUnitsSelectPrevByTheFloor(t *testing.T) {
if tc.journal {
os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0o600)
}
- if tc.floor != "" {
- os.WriteFile(filepath.Join(data, moves.FloorMinReleaseFile), []byte(tc.floor), 0o644)
+ floorPath := filepath.Join(data, moves.FloorMinReleaseFile)
+ switch tc.floorKind {
+ case "symlink", "dangling":
+ target := filepath.Join(root, "floor-target")
+ if tc.floorKind == "symlink" {
+ if err := os.WriteFile(target, []byte(tc.floor), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.Symlink(target, floorPath); err != nil {
+ t.Fatal(err)
+ }
+ case "directory":
+ if err := os.Mkdir(floorPath, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ default:
+ if tc.floor != "" {
+ if err := os.WriteFile(floorPath, []byte(tc.floor), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
}
if tc.verdict != "" {
os.WriteFile(filepath.Join(opt, moves.PrevAllowedFile), []byte(tc.verdict), 0o644)
diff --git a/internal/host/memwatch.go b/internal/host/memwatch.go
index 0a74e14f6..a6eb39e3e 100644
--- a/internal/host/memwatch.go
+++ b/internal/host/memwatch.go
@@ -11,9 +11,9 @@ import (
"time"
"github.com/aws/aws-sdk-go-v2/aws"
- awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/cloudwatch"
"github.com/aws/aws-sdk-go-v2/service/cloudwatch/types"
+ "github.com/carlosframework/platform/internal/rootaws"
)
// memwatch (memory-pressure spec §2.4) publishes two CloudWatch metrics a
@@ -88,7 +88,7 @@ func memwatchLoop() {
// own object store (internal/lease/endpoint.go's note) would aim the
// CloudWatch client at nonsense, which is one more reason this duty
// is opt-in per box rather than on wherever credentials resolve.
- cfg, err := awsconfig.LoadDefaultConfig(ctx)
+ cfg, err := rootaws.LoadDefaultConfig(ctx)
if err != nil {
log.Printf("agent: memwatch disabled: load aws config: %v", err)
return
diff --git a/internal/host/offcloudcredentials_linux.go b/internal/host/offcloudcredentials_linux.go
new file mode 100644
index 000000000..917269628
--- /dev/null
+++ b/internal/host/offcloudcredentials_linux.go
@@ -0,0 +1,131 @@
+//go:build linux
+
+package host
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "os/user"
+ "strconv"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/rootaws"
+ "github.com/carlosframework/platform/internal/safefile"
+ "golang.org/x/sys/unix"
+)
+
+// openOffcloudCredentials pins the parent and refuses links and special files
+// before either ownership or mode is changed. O_PATH inspects special files
+// without opening a device or blocking on a planted FIFO.
+func openOffcloudCredentials(path string) (_ *os.File, err error) {
+ defer func() {
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ log.Printf("ALERT: refusing unsafe offcloud credentials %s: %v", path, err)
+ }
+ }()
+ parent, name, err := openRegistryParent(path)
+ if err != nil {
+ return nil, err
+ }
+ defer parent.Close()
+ fd, err := unix.Openat2(int(parent.Fd()), name, &unix.OpenHow{
+ Flags: unix.O_PATH | unix.O_NOFOLLOW | unix.O_CLOEXEC,
+ Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV,
+ })
+ if err != nil {
+ return nil, err
+ }
+ f := os.NewFile(uintptr(fd), path)
+ defer f.Close()
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil {
+ return nil, err
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 {
+ return nil, fmt.Errorf("not a plain single-link regular file")
+ }
+ // Reopen the pinned regular inode, never the caller's mutable pathname.
+ opened, err := unix.Open(fmt.Sprintf("/proc/self/fd/%d", fd), unix.O_RDONLY|unix.O_CLOEXEC, 0)
+ if err != nil {
+ return nil, err
+ }
+ return os.NewFile(uintptr(opened), path), nil
+}
+
+func checkOffcloudCredentials(path string) (os.FileInfo, error) {
+ f, err := openOffcloudCredentials(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ return f.Stat()
+}
+
+// Missing means a cloud box. Repair replaces the inode so a service process
+// holding a writable descriptor cannot change the credentials root will read.
+func repairOffcloudCredentials(path string) (err error) {
+ defer func() {
+ if err != nil {
+ log.Printf("ALERT: refusing unsafe offcloud credentials %s: %v", path, err)
+ }
+ }()
+ f, err := openOffcloudCredentials(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ g, err := user.LookupGroup(serviceUser)
+ if err != nil {
+ return err
+ }
+ gid, err := strconv.Atoi(g.Gid)
+ if err != nil {
+ return err
+ }
+ return secureOffcloudCredentials(f, gid)
+}
+
+func secureOffcloudCredentials(f *os.File, gid int) (err error) {
+ defer func() {
+ if err != nil {
+ log.Printf("ALERT: refusing unsafe offcloud credentials %s: %v", f.Name(), err)
+ }
+ }()
+ var st unix.Stat_t
+ if err := unix.Fstat(int(f.Fd()), &st); err != nil {
+ return err
+ }
+ body, err := io.ReadAll(io.LimitReader(f, rootaws.MaxCredentialsSize+1))
+ if err != nil {
+ return err
+ }
+ if len(body) > rootaws.MaxCredentialsSize {
+ return io.ErrShortBuffer
+ }
+ credentials, err := rootaws.ParseStaticCredentials(body)
+ if err != nil {
+ return err
+ }
+ if st.Uid == 0 && st.Gid == uint32(gid) && st.Mode&0o7777 == 0o640 {
+ return nil
+ }
+ var validated strings.Builder
+ fmt.Fprintf(&validated, "[default]\naws_access_key_id=%s\naws_secret_access_key=%s\n", credentials.AccessKeyID, credentials.SecretAccessKey)
+ if credentials.SessionToken != "" {
+ fmt.Fprintf(&validated, "aws_session_token=%s\n", credentials.SessionToken)
+ }
+ return safefile.Write(f.Name(), 0o640, func(w io.Writer) error {
+ fresh := w.(*os.File)
+ if err := fresh.Chown(0, gid); err != nil {
+ return err
+ }
+ _, err := io.WriteString(w, validated.String())
+ return err
+ })
+}
diff --git a/internal/host/offcloudcredentials_linux_test.go b/internal/host/offcloudcredentials_linux_test.go
new file mode 100644
index 000000000..fafedfedb
--- /dev/null
+++ b/internal/host/offcloudcredentials_linux_test.go
@@ -0,0 +1,251 @@
+//go:build linux
+
+package host
+
+import (
+ "bytes"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "golang.org/x/sys/unix"
+)
+
+func TestOffcloudCredentialsCreationAndRepair(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("root required for root:service ownership")
+ }
+ const serviceID = 65534
+ for _, owner := range []int{0, serviceID} {
+ t.Run(map[int]string{0: "creation", serviceID: "repair"}[owner], func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "credentials")
+ contents := []byte("[default]\naws_access_key_id=X\naws_secret_access_key=Y\n")
+ if err := os.WriteFile(path, contents, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chown(path, owner, serviceID); err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 2; i++ {
+ f, err := openOffcloudCredentials(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = secureOffcloudCredentials(f, serviceID)
+ f.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var st unix.Stat_t
+ if err := unix.Lstat(path, &st); err != nil {
+ t.Fatal(err)
+ }
+ if st.Uid != 0 || st.Gid != serviceID || st.Mode&0o7777 != 0o640 {
+ t.Fatalf("got owner %d:%d mode %o", st.Uid, st.Gid, st.Mode&0o7777)
+ }
+ }
+ if got, err := os.ReadFile(path); err != nil || !bytes.Equal(got, contents) {
+ t.Fatalf("credentials changed: %q, %v", got, err)
+ }
+ })
+ }
+}
+
+func TestOffcloudCredentialsRepairReplacesWritableInode(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("root required to replace service-owned credentials with root:service ownership")
+ }
+ const serviceID = 65534
+ path := filepath.Join(t.TempDir(), "credentials")
+ contents := []byte("[default]\naws_access_key_id=X\naws_secret_access_key=Y\naws_session_token=Z\n")
+ if err := os.WriteFile(path, contents, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chown(path, serviceID, serviceID); err != nil {
+ t.Fatal(err)
+ }
+ writable, err := os.OpenFile(path, os.O_RDWR, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer writable.Close()
+ old, err := writable.Stat()
+ if err != nil {
+ t.Fatal(err)
+ }
+ f, err := openOffcloudCredentials(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if err := secureOffcloudCredentials(f, serviceID); err != nil {
+ t.Fatal(err)
+ }
+ fresh, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if os.SameFile(old, fresh) {
+ t.Fatal("repair kept the service-writable inode")
+ }
+ if _, err := writable.WriteAt([]byte("[default]\ncredential_process=/bin/false\n"), 0); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := os.ReadFile(path); err != nil || !bytes.Equal(got, contents) {
+ t.Fatalf("old writable descriptor affected repaired credentials: %q, %v", got, err)
+ }
+}
+
+func TestOffcloudCredentialsCorrectMetadataUnchanged(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("root required to verify root:service ownership is left unchanged")
+ }
+ const serviceID = 65534
+ path := filepath.Join(t.TempDir(), "credentials")
+ contents := []byte("# preserve this file verbatim\n[default]\naws_access_key_id = X\naws_secret_access_key = Y\n")
+ if err := os.WriteFile(path, contents, 0o640); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chown(path, 0, serviceID); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(path, 0o640); err != nil {
+ t.Fatal(err)
+ }
+ var before, after unix.Stat_t
+ if err := unix.Stat(path, &before); err != nil {
+ t.Fatal(err)
+ }
+ f, err := openOffcloudCredentials(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if err := secureOffcloudCredentials(f, serviceID); err != nil {
+ t.Fatal(err)
+ }
+ if err := unix.Stat(path, &after); err != nil {
+ t.Fatal(err)
+ }
+ if before.Ino != after.Ino || before.Ctim != after.Ctim || before.Mode != after.Mode || before.Uid != after.Uid || before.Gid != after.Gid {
+ t.Fatal("repair mutated already-correct credential metadata")
+ }
+ if got, err := os.ReadFile(path); err != nil || !bytes.Equal(got, contents) {
+ t.Fatalf("already-correct credentials changed: %q, %v", got, err)
+ }
+}
+
+func TestOffcloudCredentialsInvalidContentsNotRepaired(t *testing.T) {
+ for _, contents := range []string{
+ "[default]\ncredential_process=/bin/false\n",
+ "[default]\naws_access_key_id=X\naws_secret_access_key=Y\nregion=eu-west-1\n",
+ "[default]\naws_access_key_id=X\n",
+ "[other]\naws_access_key_id=X\naws_secret_access_key=Y\n",
+ } {
+ t.Run(strings.ReplaceAll(contents, "\n", ";"), func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "credentials")
+ if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var before, after unix.Stat_t
+ if err := unix.Stat(path, &before); err != nil {
+ t.Fatal(err)
+ }
+ f, err := openOffcloudCredentials(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ var logs bytes.Buffer
+ previous := log.Writer()
+ log.SetOutput(&logs)
+ defer log.SetOutput(previous)
+ if err := secureOffcloudCredentials(f, os.Getegid()); err == nil {
+ t.Fatal("accepted invalid credentials")
+ }
+ if !strings.Contains(logs.String(), "ALERT:") {
+ t.Fatalf("refusal did not alert: %s", logs.String())
+ }
+ if err := unix.Stat(path, &after); err != nil {
+ t.Fatal(err)
+ }
+ if before.Ino != after.Ino || before.Ctim != after.Ctim || before.Mode != after.Mode || before.Uid != after.Uid || before.Gid != after.Gid {
+ t.Fatal("invalid credentials metadata changed")
+ }
+ if got, err := os.ReadFile(path); err != nil || string(got) != contents {
+ t.Fatalf("invalid credentials changed: %q, %v", got, err)
+ }
+ })
+ }
+}
+
+func TestOffcloudCredentialsRefuseLinksAndSpecials(t *testing.T) {
+ for _, kind := range []string{"symlink", "dangling", "hardlink", "fifo", "directory", "parent-symlink"} {
+ t.Run(kind, func(t *testing.T) {
+ root := t.TempDir()
+ path := filepath.Join(root, "credentials")
+ victim := filepath.Join(root, "victim")
+ if err := os.WriteFile(victim, []byte("preserve"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var err error
+ switch kind {
+ case "symlink":
+ err = os.Symlink(victim, path)
+ case "dangling":
+ err = os.Symlink(filepath.Join(root, "absent"), path)
+ case "hardlink":
+ err = os.Link(victim, path)
+ case "fifo":
+ err = unix.Mkfifo(path, 0o600)
+ case "directory":
+ err = os.Mkdir(path, 0o600)
+ case "parent-symlink":
+ err = os.Symlink(root, filepath.Join(root, "linked-parent"))
+ path = filepath.Join(root, "linked-parent", "victim")
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ var logs bytes.Buffer
+ previous := log.Writer()
+ log.SetOutput(&logs)
+ defer log.SetOutput(previous)
+ if err := repairOffcloudCredentials(path); err == nil {
+ t.Fatal("accepted unsafe credentials")
+ }
+ if !strings.Contains(logs.String(), "ALERT:") {
+ t.Fatalf("refusal did not alert: %s", logs.String())
+ }
+ fi, err := os.Stat(victim)
+ if err != nil || fi.Mode().Perm() != 0o600 {
+ t.Fatalf("victim mode changed: %v, %v", fi, err)
+ }
+ if got, err := os.ReadFile(victim); err != nil || string(got) != "preserve" {
+ t.Fatalf("victim changed: %q, %v", got, err)
+ }
+ })
+ }
+}
+
+func TestOffcloudCredentialsMissingCloudFile(t *testing.T) {
+ if err := repairOffcloudCredentials(filepath.Join(t.TempDir(), "absent", "credentials")); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestBootstrapOffcloudAcceptsRepairedCredentials(t *testing.T) {
+ root := t.TempDir()
+ path := filepath.Join(root, "etc", "carlos", "credentials")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("[default]\n"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ if err := bootstrapOffcloud(root, nil); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/host/offcloudcredentials_other.go b/internal/host/offcloudcredentials_other.go
new file mode 100644
index 000000000..2af2da277
--- /dev/null
+++ b/internal/host/offcloudcredentials_other.go
@@ -0,0 +1,16 @@
+//go:build !linux
+
+package host
+
+import (
+ "fmt"
+ "os"
+)
+
+func checkOffcloudCredentials(string) (os.FileInfo, error) {
+ return nil, fmt.Errorf("offcloud credentials operations require Linux")
+}
+
+func repairOffcloudCredentials(string) error {
+ return fmt.Errorf("offcloud credentials operations require Linux")
+}
diff --git a/internal/host/registrychild_exec_linux.go b/internal/host/registrychild_exec_linux.go
index 84e324f62..76d616956 100644
--- a/internal/host/registrychild_exec_linux.go
+++ b/internal/host/registrychild_exec_linux.go
@@ -3,9 +3,13 @@
package host
import (
+ "fmt"
"os"
"os/exec"
+ "os/user"
+ "runtime"
"strconv"
+ "syscall"
"golang.org/x/sys/unix"
)
@@ -22,9 +26,76 @@ func registryChildExec(path string, uid, gid int) (*exec.Cmd, error) {
}
func registryChildCloseExec() error {
+ // This marks descriptors in the parent too; Go opens its own descriptors
+ // close-on-exec, and ExtraFiles explicitly preserves the pinned executable.
return unix.CloseRange(3, ^uint(0), unix.CLOSE_RANGE_CLOEXEC)
}
+func registryChildStart(cmd *exec.Cmd) error {
+ started := make(chan error, 1)
+ go func() {
+ runtime.LockOSThread()
+ // Never unlock: exiting this goroutine retires this OS thread. The
+ // parent keeps its privileges; every thread in the exec'd child inherits
+ // no-new-privileges, including threads created by cgo or the Go runtime.
+ if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
+ started <- fmt.Errorf("registry: no new privileges: %w", err)
+ return
+ }
+ started <- cmd.Start()
+ }()
+ return <-started
+}
+
+func registryChildHarden() error {
+ // Exec as root keeps runtime startup inaccessible to the service user.
+ // Resolve the fixed service identity without inspecting child arguments.
+ // syscall's credential setters update every thread, including with cgo.
+ if err := syscall.Setgroups(nil); err != nil {
+ return err
+ }
+ service, err := user.Lookup(serviceUser)
+ if err != nil {
+ return err
+ }
+ uid, err := strconv.Atoi(service.Uid)
+ if err != nil {
+ return err
+ }
+ gid, err := strconv.Atoi(service.Gid)
+ if err != nil {
+ return err
+ }
+ if uid <= 0 || gid < 0 {
+ return fmt.Errorf("registry: invalid service credentials")
+ }
+ if err := syscall.Setresgid(gid, gid, gid); err != nil {
+ return err
+ }
+ if err := syscall.Setresuid(uid, uid, uid); err != nil {
+ return err
+ }
+ // Changing credentials resets dumpability to suid_dumpable. Explicitly
+ // require zero too, before reading any registry arguments or pipe input.
+ if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil {
+ return fmt.Errorf("registry: disable dumpability: %w", err)
+ }
+ dumpable, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0)
+ if err != nil {
+ return err
+ }
+ ruid, euid, suid := unix.Getresuid()
+ rgid, egid, sgid := unix.Getresgid()
+ groups, err := os.Getgroups()
+ if err != nil {
+ return err
+ }
+ if dumpable != 0 || ruid != uid || euid != uid || suid != uid || rgid != gid || egid != gid || sgid != gid || len(groups) != 0 {
+ return fmt.Errorf("registry: child credentials were not dropped")
+ }
+ return nil
+}
+
func closeRegistryExecutable() {
// fd 3 is the parent's pinned executable, passed only for exec.
unix.Close(3)
diff --git a/internal/host/registrychild_exec_linux_test.go b/internal/host/registrychild_exec_linux_test.go
index 913f55656..0cafb04be 100644
--- a/internal/host/registrychild_exec_linux_test.go
+++ b/internal/host/registrychild_exec_linux_test.go
@@ -3,19 +3,119 @@
package host
import (
+ "bytes"
+ "fmt"
"io"
"os"
"os/exec"
"os/user"
"path/filepath"
+ "runtime"
"strconv"
"strings"
+ "syscall"
"testing"
"github.com/carlosframework/platform/internal/registry"
"golang.org/x/sys/unix"
)
+func TestRegistryChildHardeningHelper(t *testing.T) {
+ if os.Args[len(os.Args)-1] != "--hardening-probe" {
+ return
+ }
+ if os.Geteuid() != 0 {
+ t.Fatal("child lost root before its in-process credential drop")
+ }
+ service, err := user.Lookup(serviceUser)
+ if err != nil {
+ t.Fatal(err)
+ }
+ uid, err := strconv.Atoi(service.Uid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ gid, err := strconv.Atoi(service.Gid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Malformed input must still drop all credentials and disable dumpability.
+ if err := RunRegistryChild(nil); err == nil {
+ t.Fatal("invalid arguments accepted")
+ }
+ ruid, euid, suid := unix.Getresuid()
+ rgid, egid, sgid := unix.Getresgid()
+ groups, err := os.Getgroups()
+ if err != nil || ruid != uid || euid != uid || suid != uid || rgid != gid || egid != gid || sgid != gid || len(groups) != 0 {
+ t.Fatalf("child credentials = uid %d:%d:%d, gid %d:%d:%d, groups %v: %v", ruid, euid, suid, rgid, egid, sgid, groups, err)
+ }
+ dumpable, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0)
+ if err != nil || dumpable != 0 {
+ t.Fatalf("dumpable = %d, %v", dumpable, err)
+ }
+ threads, err := os.ReadDir("/proc/self/task")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, thread := range threads {
+ status, err := os.ReadFile(filepath.Join("/proc/self/task", thread.Name(), "status"))
+ if os.IsNotExist(err) {
+ continue
+ }
+ if err != nil || !bytes.Contains(status, []byte("NoNewPrivs:\t1\n")) {
+ t.Fatalf("thread %s lacks no-new-privileges: %v", thread.Name(), err)
+ }
+ for _, want := range []string{
+ fmt.Sprintf("Uid:\t%d\t%d\t%d\t%d\n", uid, uid, uid, uid),
+ fmt.Sprintf("Gid:\t%d\t%d\t%d\t%d\n", gid, gid, gid, gid),
+ "Groups:\t\n",
+ } {
+ if !bytes.Contains(status, []byte(want)) {
+ t.Fatalf("thread %s lacks exact credentials %q: %s", thread.Name(), want, status)
+ }
+ }
+ }
+ if err := syscall.Setresuid(0, 0, 0); err == nil {
+ t.Fatal("child regained root uid")
+ }
+ if err := syscall.Setresgid(0, 0, 0); err == nil {
+ t.Fatal("child regained root gid")
+ }
+}
+
+func TestRegistryChildHardening(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("requires root to verify the child's in-process credential drop and dumpability")
+ }
+ if _, err := user.Lookup(serviceUser); err != nil {
+ t.Skip("requires the carlos service account")
+ }
+ // Keep this thread pinned while verifying the spawn did not change it.
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ before, err := unix.PrctlRetInt(unix.PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ executable, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command(executable, "-test.run=^TestRegistryChildHardeningHelper$", "--", "--hardening-probe")
+ var output bytes.Buffer
+ cmd.Stdout, cmd.Stderr = &output, &output
+ if err := registryChildStart(cmd); err != nil {
+ t.Fatal(err)
+ }
+ if err := cmd.Wait(); err != nil {
+ t.Fatalf("hardening probe: %v: %s", err, output.String())
+ }
+ after, err := unix.PrctlRetInt(unix.PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)
+ if err != nil || after != before {
+ t.Fatalf("parent no-new-privileges changed from %d to %d: %v", before, after, err)
+ }
+}
+
func TestRegistryChildFDHelper(t *testing.T) {
if len(os.Args) == 0 || os.Args[len(os.Args)-1] != "--fd-probe" {
return
diff --git a/internal/host/registrychild_exec_other.go b/internal/host/registrychild_exec_other.go
index 1b1ec6472..52e9f696f 100644
--- a/internal/host/registrychild_exec_other.go
+++ b/internal/host/registrychild_exec_other.go
@@ -15,4 +15,12 @@ func registryChildCloseExec() error {
return errors.New("registry: privileged child requires Linux")
}
+func registryChildStart(cmd *exec.Cmd) error {
+ return errors.New("registry: privileged child requires Linux")
+}
+
+func registryChildHarden() error {
+ return errors.New("registry: privileged child requires Linux")
+}
+
func closeRegistryExecutable() {}
diff --git a/internal/host/registrychild_unix.go b/internal/host/registrychild_unix.go
index ab98a09eb..bae0307e4 100644
--- a/internal/host/registrychild_unix.go
+++ b/internal/host/registrychild_unix.go
@@ -10,7 +10,6 @@ import (
"os/exec"
"path/filepath"
"strconv"
- "syscall"
"github.com/carlosframework/platform/internal/registry"
)
@@ -65,7 +64,7 @@ func registryChildPipes(cmd *exec.Cmd, uid, gid int) (io.WriteCloser, io.ReadClo
cmd.Env = append(cmd.Env, "CARLOS_HOST_CLAIM="+value)
}
cmd.Dir = "/"
- cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid), Groups: []uint32{}}}
+ cmd.SysProcAttr = nil
cmd.Stderr = os.Stderr
input, err := cmd.StdinPipe()
if err != nil {
@@ -76,7 +75,7 @@ func registryChildPipes(cmd *exec.Cmd, uid, gid int) (io.WriteCloser, io.ReadClo
input.Close()
return nil, nil, nil, err
}
- if err = cmd.Start(); err != nil {
+ if err = registryChildStart(cmd); err != nil {
input.Close()
output.Close()
return nil, nil, nil, err
@@ -86,6 +85,9 @@ func registryChildPipes(cmd *exec.Cmd, uid, gid int) (io.WriteCloser, io.ReadClo
// RunRegistryChild is the private re-exec entry point, before CLI dispatch.
func RunRegistryChild(args []string) error {
+ if err := registryChildHarden(); err != nil {
+ return err
+ }
closeRegistryExecutable()
readOnly := len(args) == 4 && args[3] == "read-only"
if len(args) != 3 && !readOnly {
diff --git a/internal/host/registrychild_unix_test.go b/internal/host/registrychild_unix_test.go
index 295acfe36..974c2be08 100644
--- a/internal/host/registrychild_unix_test.go
+++ b/internal/host/registrychild_unix_test.go
@@ -31,9 +31,9 @@ func TestRegistryChildDropsCredentials(t *testing.T) {
t.Skip("requires root to set the child uid/gid and clear supplementary groups")
}
t.Setenv("CARLOS_HOST_CLAIM", "true")
- service, err := user.Lookup("nobody")
+ service, err := user.Lookup(serviceUser)
if err != nil {
- t.Skip("requires an unprivileged nobody account")
+ t.Skip("requires the carlos service account")
}
uid, err := strconv.Atoi(service.Uid)
if err != nil || uid <= 0 {
@@ -155,7 +155,7 @@ func TestRegistryChildCommandEnvironment(t *testing.T) {
if len(cmd.Env) != 1 || cmd.Env[0] != "CARLOS_HOST_CLAIM=true" {
t.Fatalf("worker environment = %q", cmd.Env)
}
- if cmd.Dir != "/" || cmd.SysProcAttr.Credential.Uid == 0 || cmd.SysProcAttr.Credential.NoSetGroups || len(cmd.SysProcAttr.Credential.Groups) != 0 {
+ if cmd.Dir != "/" || cmd.SysProcAttr != nil {
t.Fatalf("unsafe worker command: %+v", cmd)
}
}
diff --git a/internal/host/stagingcleanup.go b/internal/host/stagingcleanup.go
new file mode 100644
index 000000000..8f631e04d
--- /dev/null
+++ b/internal/host/stagingcleanup.go
@@ -0,0 +1,32 @@
+package host
+
+import (
+ "path/filepath"
+
+ "github.com/carlosframework/platform/internal/safefile"
+)
+
+// reapStagingWrites runs before a pass needs free space for its own writes.
+// Production callers run as root, so service-owned scratch is never removed.
+func reapStagingWrites(opt, data, etc string, logf func(string, ...any)) {
+ roots := []string{opt, data}
+ if opt != "" {
+ // Descendant traversal refuses mount crossings. Open known writer
+ // roots separately too, for boxes with a dedicated versions disk.
+ roots = append(roots, filepath.Join(opt, "versions"), filepath.Join(opt, "staging"),
+ filepath.Join(systemRootDir(opt), "staging"))
+ }
+ if etc != "" {
+ roots = append(roots, filepath.Join(etc, "carlos"), filepath.Join(etc, "systemd", "system"),
+ filepath.Join(etc, "systemd", "journald.conf.d"), filepath.Join(etc, "sysusers.d"),
+ filepath.Join(etc, "tmpfiles.d"))
+ }
+ for _, root := range roots {
+ if root == "" {
+ continue
+ }
+ if err := safefile.Reap(root); err != nil {
+ logf("staging cleanup: %s: %v", root, err)
+ }
+ }
+}
diff --git a/internal/host/stagingcleanup_linux_test.go b/internal/host/stagingcleanup_linux_test.go
new file mode 100644
index 000000000..fc76e499f
--- /dev/null
+++ b/internal/host/stagingcleanup_linux_test.go
@@ -0,0 +1,74 @@
+package host
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestSystemApplyReapsStagingBeforeLockCreation(t *testing.T) {
+ f := newApplyFixture(t, "stable", "v1")
+ roots := []string{
+ f.a.optDir,
+ filepath.Join(f.a.optDir, "versions", "acct", "v1"),
+ f.a.stagingDir(),
+ filepath.Join(f.a.dataDir, "releases", "healthy"),
+ filepath.Join(f.a.etcDir, "carlos"),
+ filepath.Join(f.a.etcDir, "systemd", "system"),
+ filepath.Join(f.a.etcDir, "systemd", "journald.conf.d"),
+ filepath.Join(f.a.etcDir, "sysusers.d"),
+ filepath.Join(f.a.etcDir, "tmpfiles.d"),
+ }
+ var stale, retained []string
+ for _, root := range roots {
+ for _, tc := range []struct {
+ name string
+ old bool
+ }{
+ {".carlos-write-" + strings.Repeat("a", 32), true},
+ {".carlos-write-" + strings.Repeat("b", 32), false},
+ {".carlos-write-unrelated", true},
+ } {
+ path := filepath.Join(root, tc.name)
+ if err := os.MkdirAll(path, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(path, "data"), []byte("interrupted"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if tc.old {
+ old := time.Now().Add(-2 * time.Hour)
+ for _, entry := range []string{filepath.Join(path, "data"), path} {
+ if err := os.Chtimes(entry, old, old); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if tc.old && strings.HasSuffix(tc.name, strings.Repeat("a", 32)) {
+ stale = append(stale, path)
+ } else {
+ retained = append(retained, path)
+ }
+ }
+ }
+ // Fail at lock creation, proving cleanup needs neither a usable lock nor
+ // a successful recovery (which independently removes system staging).
+ if err := os.Mkdir(f.a.lockPath(), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.a.Run(t.Context(), false); err == nil {
+ t.Fatal("directory accepted as lock file")
+ }
+ for _, path := range stale {
+ if _, err := os.Lstat(path); !os.IsNotExist(err) {
+ t.Errorf("stale staging remains at %s: %v", path, err)
+ }
+ }
+ for _, path := range retained {
+ if _, err := os.Stat(filepath.Join(path, "data")); err != nil {
+ t.Errorf("retained staging changed at %s: %v", path, err)
+ }
+ }
+}
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go
index 73a8902e9..9825fb1b3 100644
--- a/internal/host/systemapply.go
+++ b/internal/host/systemapply.go
@@ -256,6 +256,7 @@ func (a *systemApplier) file(name string) string { return filepath.Join(a.rootDi
// journal is closed, and re-firing OnFailure= over a finished recovery
// would only start it again with nothing left to do.
func (a *systemApplier) Run(ctx context.Context, recover bool) (retErr error) {
+ reapStagingWrites(a.optDir, a.dataDir, a.etcDir, a.ops.logf)
if err := os.MkdirAll(a.rootDir(), 0o755); err != nil {
return err
}
diff --git a/internal/host/units/carlos-system-recover.service b/internal/host/units/carlos-system-recover.service
index 3da2b581b..ea6465c10 100644
--- a/internal/host/units/carlos-system-recover.service
+++ b/internal/host/units/carlos-system-recover.service
@@ -26,7 +26,7 @@ DefaultDependencies=yes
Type=oneshot
TimeoutStartSec=10min
EnvironmentFile=-/etc/carlos/host.env
-ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; }; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover'
+ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && [ ! -L /data/.moves/floor.min-release ] && { [ ! -e /data/.moves/floor.min-release ] || [ -f /data/.moves/floor.min-release ]; } && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; }; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover'
ProtectHome=yes
PrivateTmp=yes
diff --git a/internal/host/units/carlos-system-update.service b/internal/host/units/carlos-system-update.service
index 9bc8d25f9..98b61651c 100644
--- a/internal/host/units/carlos-system-update.service
+++ b/internal/host/units/carlos-system-update.service
@@ -32,7 +32,7 @@ OnFailure=carlos-system-recover.service
Type=oneshot
TimeoutStartSec=15min
EnvironmentFile=-/etc/carlos/host.env
-ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; }; then exec /opt/carlos/carlos.prev system apply; fi; exec /opt/carlos/carlos system apply'
+ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && [ ! -L /data/.moves/floor.min-release ] && { [ ! -e /data/.moves/floor.min-release ] || [ -f /data/.moves/floor.min-release ]; } && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; }; then exec /opt/carlos/carlos.prev system apply; fi; exec /opt/carlos/carlos system apply'
# --- hardening (the carlos-stage.service set) ---
ProtectHome=yes
diff --git a/internal/lease/checkpoint.go b/internal/lease/checkpoint.go
index a7d7d1ec9..573208fdd 100644
--- a/internal/lease/checkpoint.go
+++ b/internal/lease/checkpoint.go
@@ -26,7 +26,10 @@ func (h *Held) Checkpoint(ctx context.Context, dbPath string) (receipt Checkpoin
stop := context.AfterFunc(h.rn.ctx, cancel)
defer stop()
defer cancel()
- store, db := h.newStore(dbPath, 0)
+ store, db, err := h.newStore(dbPath, 0)
+ if err != nil {
+ return receipt, err
+ }
// Short checkpoints need only the explicit L0 sync. Background snapshots
// are unnecessary and v0.5.14 closes the DB before joining those monitors.
db.MonitorInterval = 0
diff --git a/internal/lease/claimgeneration.go b/internal/lease/claimgeneration.go
index d02f72154..003e848d5 100644
--- a/internal/lease/claimgeneration.go
+++ b/internal/lease/claimgeneration.go
@@ -39,7 +39,11 @@ func (h *Held) BindGeneration(prefix string, check func(context.Context) error)
}
client := h.replicaClient
if client == nil {
- client = newReplicaClient(h.leaser.Bucket, clean, h.region)
+ var err error
+ client, err = newReplicaClient(h.leaser.Bucket, clean, h.region)
+ if err != nil {
+ return err
+ }
}
h.replicaClient = &generationClient{client: client, check: check}
h.generationPrefix = clean
@@ -69,7 +73,11 @@ func (h *Held) BindRoot(check func(context.Context) error) error {
}
client := h.replicaClient
if client == nil {
- client = newReplicaClient(h.leaser.Bucket, base, h.region)
+ var err error
+ client, err = newReplicaClient(h.leaser.Bucket, base, h.region)
+ if err != nil {
+ return err
+ }
}
h.replicaClient = &generationClient{client: client, check: check}
// The lease's own path marks the binding, so neither BindGeneration nor
diff --git a/internal/lease/claimgeneration_test.go b/internal/lease/claimgeneration_test.go
index 68b87a421..65692522a 100644
--- a/internal/lease/claimgeneration_test.go
+++ b/internal/lease/claimgeneration_test.go
@@ -154,7 +154,10 @@ func TestGenerationClientDisablesRemoteRetentionInRealStore(t *testing.T) {
t.Fatal(err)
}
}
- _, db := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second)
+ _, db, err := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
if db.RetentionEnabled == bound {
t.Fatalf("bound=%v retention=%v", bound, db.RetentionEnabled)
}
@@ -169,7 +172,10 @@ func TestGenerationClientRealRetentionMonitorsDoNotDelete(t *testing.T) {
if err := h.BindGeneration(l.Path+"/g-one/", func(context.Context) error { return nil }); err != nil {
t.Fatal(err)
}
- store, db := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second)
+ store, db, err := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
if store.RetentionEnabled || db.RetentionEnabled {
t.Fatal("remote retention enabled")
}
diff --git a/internal/lease/endpoint.go b/internal/lease/endpoint.go
index c2e3dfe78..37deb79a4 100644
--- a/internal/lease/endpoint.go
+++ b/internal/lease/endpoint.go
@@ -16,11 +16,15 @@ package lease
import (
"context"
+ "errors"
"fmt"
+ "os"
+ "github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
lss3 "github.com/benbjohnson/litestream/s3"
+ "github.com/carlosframework/platform/internal/rootaws"
"github.com/carlosframework/platform/internal/s3compat"
)
@@ -59,7 +63,7 @@ func resolveRegion(ctx context.Context, region string) (string, error) {
if region != "" {
return region, nil // no config load needed: step 1 already decided
}
- cfg, err := awsconfig.LoadDefaultConfig(ctx)
+ cfg, err := rootaws.LoadDefaultConfig(ctx)
if err != nil {
return "", fmt.Errorf("load aws config: %w", err)
}
@@ -101,7 +105,7 @@ func newLeaserClient(ctx context.Context, region string) (*awss3.Client, string,
if region != "" {
opts = append(opts, awsconfig.WithRegion(region))
}
- cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
+ cfg, err := rootaws.LoadDefaultConfig(ctx, opts...)
if err != nil {
return nil, "", fmt.Errorf("load aws config: %w", err)
}
@@ -117,8 +121,25 @@ func newLeaserClient(ctx context.Context, region string) (*awss3.Client, string,
// the non-AWS default region) from a non-empty Endpoint on its own, and the
// signed-payload and content-MD5 defaults NewReplicaClient already ships are
// the ones every compatible in litestream's provider table wants.
-func newReplicaClient(bucket, prefix, region string) *lss3.ReplicaClient {
+func newReplicaClient(bucket, prefix, region string) (*lss3.ReplicaClient, error) {
+ return newReplicaClientForUID(bucket, prefix, region, os.Geteuid(), rootaws.OffcloudCredentials)
+}
+
+var errRootReplicaSessionToken = errors.ErrUnsupported
+
+func newReplicaClientForUID(bucket, prefix, region string, euid int, offcloud func() (aws.Credentials, error)) (*lss3.ReplicaClient, error) {
c := lss3.NewReplicaClient()
+ if euid == 0 {
+ creds, err := offcloud()
+ if err != nil {
+ return nil, err
+ }
+ if creds.SessionToken != "" {
+ return nil, errRootReplicaSessionToken
+ }
+ c.AccessKeyID = creds.AccessKeyID
+ c.SecretAccessKey = creds.SecretAccessKey
+ }
c.Bucket = bucket
c.Path = prefix
c.Region = region
@@ -126,7 +147,7 @@ func newReplicaClient(bucket, prefix, region string) *lss3.ReplicaClient {
c.Endpoint = ep
c.ForcePathStyle = true
}
- return c
+ return c, nil
}
// ReadReplicaClient is newReplicaClient for READERS outside this package —
@@ -145,5 +166,5 @@ func ReadReplicaClient(ctx context.Context, bucket, prefix, region string) (*lss
if err != nil {
return nil, err
}
- return newReplicaClient(bucket, prefix, region), nil
+ return newReplicaClient(bucket, prefix, region)
}
diff --git a/internal/lease/endpoint_test.go b/internal/lease/endpoint_test.go
index 68346f032..dd341a561 100644
--- a/internal/lease/endpoint_test.go
+++ b/internal/lease/endpoint_test.go
@@ -10,17 +10,21 @@ package lease
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
"sync"
"testing"
+ "github.com/aws/aws-sdk-go-v2/aws"
"github.com/benbjohnson/litestream"
lss3 "github.com/benbjohnson/litestream/s3"
+ "github.com/carlosframework/platform/internal/rootaws"
)
// fakeS3 is just enough of S3 for the leaser's three verbs (GET/PUT/DELETE on
@@ -197,9 +201,13 @@ func TestNewLeaserClient_EndpointConfig(t *testing.T) {
// LTX files beside lock.json. A replica client that ignored the override while
// the leaser honoured it would split one lease across two stores.
func TestNewReplicaClient_EndpointConfig(t *testing.T) {
+ fakeCreds(t)
t.Run("override", func(t *testing.T) {
t.Setenv("CARLOS_S3_ENDPOINT", "https://fsn1.your-objectstorage.com")
- c := newReplicaClient("b", "p", "eu-central-1")
+ c, err := newReplicaClient("b", "p", "eu-central-1")
+ if err != nil {
+ t.Fatal(err)
+ }
if c.Endpoint != "https://fsn1.your-objectstorage.com" {
t.Errorf("Endpoint = %q, want the override", c.Endpoint)
}
@@ -213,7 +221,10 @@ func TestNewReplicaClient_EndpointConfig(t *testing.T) {
t.Run("no override", func(t *testing.T) {
t.Setenv("CARLOS_S3_ENDPOINT", "")
- c := newReplicaClient("b", "p", "eu-west-1")
+ c, err := newReplicaClient("b", "p", "eu-west-1")
+ if err != nil {
+ t.Fatal(err)
+ }
if c.Endpoint != "" || c.ForcePathStyle {
t.Errorf("Endpoint/ForcePathStyle = %q/%v, want empty/false without an override", c.Endpoint, c.ForcePathStyle)
}
@@ -338,3 +349,123 @@ func TestReadReplicaClientResolvesAnEmptyRegionLikeRestore(t *testing.T) {
t.Errorf("Endpoint = %q ForcePathStyle = %v, want the override applied", c.Endpoint, c.ForcePathStyle)
}
}
+
+func TestRootReplicaClientEnvironmentKeepsDefaultChain(t *testing.T) {
+ for _, region := range []string{"eu-west-1", ""} {
+ t.Run("region="+region, func(t *testing.T) {
+ fakeCreds(t)
+ t.Setenv("AWS_SESSION_TOKEN", "test-session-token")
+ t.Setenv("AWS_PROFILE", "")
+ t.Setenv("AWS_DEFAULT_PROFILE", "")
+ dir := t.TempDir()
+ marker := filepath.Join(dir, "executed")
+ config := filepath.Join(dir, "config")
+ if err := os.WriteFile(config, []byte("[default]\ncredential_process = touch "+marker+"\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("AWS_CONFIG_FILE", config)
+ t.Setenv("AWS_SHARED_CREDENTIALS_FILE", config)
+ var mu sync.Mutex
+ var locations, listings int
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !strings.Contains(r.Header.Get("Authorization"), "Credential=test-key/") || r.Header.Get("X-Amz-Security-Token") != "test-session-token" {
+ t.Error("request did not carry the explicit keys and session token")
+ }
+ w.Header().Set("Content-Type", "application/xml")
+ mu.Lock()
+ defer mu.Unlock()
+ if r.URL.Query().Has("location") {
+ locations++
+ fmt.Fprint(w, `<LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/">eu-west-1</LocationConstraint>`)
+ } else {
+ listings++
+ fmt.Fprint(w, `<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>b</Name><KeyCount>0</KeyCount><IsTruncated>false</IsTruncated></ListBucketResult>`)
+ }
+ }))
+ defer srv.Close()
+ // Keep Endpoint empty to exercise Litestream's bucket-region lookup.
+ t.Setenv("CARLOS_S3_ENDPOINT", "")
+ t.Setenv("AWS_ENDPOINT_URL_S3", srv.URL)
+ c, err := newReplicaClientForUID("b", "p", region, 0, rootaws.OffcloudCredentials)
+ if err != nil {
+ t.Fatal(err)
+ }
+ c.ForcePathStyle = true
+ if c.AccessKeyID != "" || c.SecretAccessKey != "" {
+ t.Fatal("environment credentials must use the upstream default chain")
+ }
+ if _, err := c.GenerationsV3(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if listings == 0 || (region == "" && locations != 1) || (region != "" && locations != 0) {
+ t.Fatalf("locations=%d listings=%d for region %q", locations, listings, region)
+ }
+ if _, err := os.Stat(marker); !os.IsNotExist(err) {
+ t.Fatal("credential_process executed")
+ }
+ })
+ }
+}
+
+func TestNonRootReplicaClientKeepsDefaultProvider(t *testing.T) {
+ c, err := newReplicaClientForUID("b", "p", "eu-west-1", 1001, func() (aws.Credentials, error) {
+ t.Fatal("non-root read root credentials")
+ return aws.Credentials{}, nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.AccessKeyID != "" || c.SecretAccessKey != "" {
+ t.Fatal("non-root credential selection changed")
+ }
+}
+
+func TestRootReplicaClientOffcloudCredentials(t *testing.T) {
+ for _, token := range []string{"", "FILE_TOKEN"} {
+ t.Run("token="+token, func(t *testing.T) {
+ body := "[default]\naws_access_key_id=FILE_ACCESS\naws_secret_access_key=FILE_SECRET\n"
+ if token != "" {
+ body += "aws_session_token=" + token + "\n"
+ }
+ c, err := newReplicaClientForUID("b", "p", "eu-west-1", 0, func() (aws.Credentials, error) {
+ return rootaws.ParseStaticCredentials([]byte(body))
+ })
+ if token != "" {
+ if !errors.Is(err, errRootReplicaSessionToken) || c != nil {
+ t.Fatalf("token-bearing file: client=%v err=%v", c, err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.AccessKeyID != "FILE_ACCESS" || c.SecretAccessKey != "FILE_SECRET" {
+ t.Fatal("off-cloud file keys were not set on the upstream client")
+ }
+ })
+ }
+}
+
+func TestRootReplicaClientWithoutOffcloudKeepsDefaultChain(t *testing.T) {
+ c, err := newReplicaClientForUID("b", "p", "eu-west-1", 0, func() (aws.Credentials, error) {
+ return aws.Credentials{}, nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.AccessKeyID != "" || c.SecretAccessKey != "" {
+ t.Fatal("instance-profile credentials must use the upstream default chain")
+ }
+}
+
+func TestRootReplicaClientRefusesUnsafeOffcloudCredentials(t *testing.T) {
+ refused := errors.New("unsafe file")
+ c, err := newReplicaClientForUID("b", "p", "eu-west-1", 0, func() (aws.Credentials, error) {
+ return aws.Credentials{}, refused
+ })
+ if !errors.Is(err, refused) || c != nil {
+ t.Fatalf("unsafe file: client=%v err=%v", c, err)
+ }
+}
diff --git a/internal/lease/lease.go b/internal/lease/lease.go
index 1de6e5479..61788e219 100644
--- a/internal/lease/lease.go
+++ b/internal/lease/lease.go
@@ -302,7 +302,10 @@ func (h *Held) Replicate(ctx context.Context, dbPath string, syncInterval time.D
stop := context.AfterFunc(h.rn.ctx, cancel)
defer stop()
defer cancel()
- store, db := h.newStore(dbPath, syncInterval)
+ store, db, err := h.newStore(dbPath, syncInterval)
+ if err != nil {
+ return nil, err
+ }
if err := store.Open(opCtx); err != nil {
cancel()
closeErr := store.Close(opCtx)
@@ -317,7 +320,7 @@ func (h *Held) Replicate(ctx context.Context, dbPath string, syncInterval time.D
return &Replicator{leaser: h.leaser, store: store, db: db, rn: h.rn}, nil
}
-func (h *Held) newStore(dbPath string, syncInterval time.Duration) (*litestream.Store, *litestream.DB) {
+func (h *Held) newStore(dbPath string, syncInterval time.Duration) (*litestream.Store, *litestream.DB, error) {
db := litestream.NewDB(dbPath)
// Preserve the activator namespace: sharing the default metadata directory
// with another replicator corrupts shadow state even across sequential runs.
@@ -325,7 +328,11 @@ func (h *Held) newStore(dbPath string, syncInterval time.Duration) (*litestream.
db.MonitorInterval = dbMonitorInterval
client := h.replicaClient
if client == nil {
- client = newReplicaClient(h.leaser.Bucket, h.leaser.Path, h.region)
+ var err error
+ client, err = newReplicaClient(h.leaser.Bucket, h.leaser.Path, h.region)
+ if err != nil {
+ return nil, nil, err
+ }
}
db.Replica = litestream.NewReplicaWithClient(db, client)
if syncInterval > 0 {
@@ -340,7 +347,7 @@ func (h *Held) newStore(dbPath string, syncInterval time.Duration) (*litestream.
store.SetRetentionEnabled(false)
}
store.L0RetentionCheckInterval = l0RetentionCheckInterval
- return store, db
+ return store, db, nil
}
// Replicator couples an exclusive S3 lease with a running litestream
@@ -453,7 +460,10 @@ func Restore(ctx context.Context, dbPath, bucket, prefix, region string) (bool,
if err != nil {
return false, err
}
- client := newReplicaClient(bucket, prefix, region)
+ client, err := newReplicaClient(bucket, prefix, region)
+ if err != nil {
+ return false, err
+ }
// A nested row's instance directory may not exist yet the first time it
// wakes: the directory is created by the spawn path (isolated-exec
diff --git a/internal/lease/retention_window_test.go b/internal/lease/retention_window_test.go
index c5235c0e1..ac38c7884 100644
--- a/internal/lease/retention_window_test.go
+++ b/internal/lease/retention_window_test.go
@@ -16,7 +16,10 @@ func TestNewStorePinsTheRecoveryWindow(t *testing.T) {
l := lss3.NewLeaser()
l.Path = "instances/x.example/inc"
h := &Held{leaser: l, replicaClient: &generationFake{}}
- store, db := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second)
+ store, db, err := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
if store.SnapshotRetention != SnapshotRetention || SnapshotRetention != 7*24*time.Hour {
t.Errorf("SnapshotRetention = %v (constant %v), want 7 days", store.SnapshotRetention, SnapshotRetention)
}
@@ -37,7 +40,10 @@ func TestNewStoreWindowLeavesAGenerationsRetentionOff(t *testing.T) {
if err := h.BindGeneration(l.Path+"/g-one/", func(context.Context) error { return nil }); err != nil {
t.Fatal(err)
}
- store, db := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second)
+ store, db, err := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
if store.RetentionEnabled || db.RetentionEnabled {
t.Fatal("a bound generation's store has retention on")
}
diff --git a/internal/lease/staging.go b/internal/lease/staging.go
index 767eef1e5..d34779e0c 100644
--- a/internal/lease/staging.go
+++ b/internal/lease/staging.go
@@ -18,6 +18,7 @@ import (
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/carlosframework/platform/internal/rootaws"
)
// The staging mirror (moves spec §7.3, model 14): the writer of a move's
@@ -129,7 +130,7 @@ func NewStagingMirror(ctx context.Context, srcRegion, dstRegion string) (*Stagin
if dstRegion != "" {
opts = append(opts, awsconfig.WithRegion(dstRegion))
}
- cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
+ cfg, err := rootaws.LoadDefaultConfig(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("load aws config: %w", err)
}
diff --git a/internal/mail/aws.go b/internal/mail/aws.go
index 682b21e1b..f57133c8a 100644
--- a/internal/mail/aws.go
+++ b/internal/mail/aws.go
@@ -39,6 +39,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/sesv2"
sesv2types "github.com/aws/aws-sdk-go-v2/service/sesv2/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
+ "github.com/carlosframework/platform/internal/rootaws"
)
// mailFromBehaviour is REJECT_MESSAGE and must stay that way.
@@ -106,7 +107,7 @@ var _ Provisioner = (*awsProvisioner)(nil)
// and composing it needs the account id. Resolving it once at construction
// keeps every walk free of an extra round trip.
func NewAWSProvisioner(ctx context.Context, region string) (Provisioner, error) {
- cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
+ cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
if err != nil {
return nil, fmt.Errorf("mail: load aws config: %w", err)
}
diff --git a/internal/objstore/aws.go b/internal/objstore/aws.go
index 931b713b5..fc765c665 100644
--- a/internal/objstore/aws.go
+++ b/internal/objstore/aws.go
@@ -18,6 +18,7 @@ import (
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
+ "github.com/carlosframework/platform/internal/rootaws"
)
// policyName is the inline policy every store user carries. One name, one
@@ -82,7 +83,7 @@ func NewAWSProvisioner(ctx context.Context, region, endpoint, boundary string) (
if err != nil {
return nil, err
}
- cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
+ cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
if err != nil {
return nil, fmt.Errorf("objstore: load aws config: %w", err)
}
@@ -97,7 +98,7 @@ func NewAWSProvisioner(ctx context.Context, region, endpoint, boundary string) (
// The economics storage sweep (console.sizeProvisionedStore) is the first:
// it sizes a tenant's provisioned bucket directly, with no IAM involved.
func S3ClientFor(ctx context.Context, region, endpoint string) (*awss3.Client, error) {
- cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
+ cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
if err != nil {
return nil, fmt.Errorf("objstore: load aws config: %w", err)
}
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 1030902f2..19472ae4e 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -538,7 +538,7 @@ var plan9B8CensusInventory = []censusEntry{
// Re-signs only the validated channel pointer; the digest-marker helper only reads.
{"internal/release/resign.go", "Signer.claimRawResign", "nonrecord", "sel:Put", "30b9a49890bde1ef0f26886c3dd3eb385de4502bc191e1e9a96cd1b75d4cc8e5"},
// Dispatches CLI verb names including instances and sidecars; record writes remain in reviewed handlers.
- {"main.go", "main", "key-site", "lit:instances,lit:sidecars", "ccfb66a13871dca203c7d8bdc7e0781aaeb34736855f64e613bf078335a358e6"},
+ {"main.go", "main", "key-site", "lit:instances,lit:sidecars", "81804c8368ee204a8d78b15161fb00bce025c03ae3ee7f920db924ab0cf4fc86"},
{"schedule.go", "renderScheduleList", "key-site", "lit:instances", "af6d666083fbbc753eecc98809d3d307344cc15110362a7e93afab573749ea6e"},
{"internal/activator/layout.go", "LayoutInputs.sockets", "key-site", "lit:instances", "1d11ac5ac5be6a42e676e064771141fe8f4367c78cbb7e5c5cca072f40c27c42"},
{"internal/activator/paths.go", "ExecBackend.pathsFor", "key-site", "lit:instances", "cbc01865e27934cb38baab9329a0867b899149531b4706a533a6956afc7c71b3"},
@@ -627,7 +627,7 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/fleetapi/fleetapi.go", "store.Put", "record-transport", "def:Put", "68f7424cbbaf8fffde48429ae29198cf6e4fcf768c512657b305224febc11186"},
{"internal/genselect/select.go", "requestLocation", "key-site", "lit:instances", "12ef5b8f94be7575ec7ab5f169736320ee6938440387fe5ea99b96f2a528cca0"},
{"internal/host/bootstrap.go", "const tmpfilesConf", "key-site", "lit:instances", "71d83f240cb1c3ab6f9dd2954b1f3e93b90133362ec12193746f60e75aeced7d"},
- {"internal/host/bootstrap.go", "bootstrapWith", "key-site", "lit:instances", "f75c2a0360970669bc72ef73285ee530dbb8ddc4457ddd73ad91f4939731b75c"},
+ {"internal/host/bootstrap.go", "bootstrapWith", "key-site", "lit:instances", "36ff18685872be8adfbf02d05676990bf9097c0fcc9695d3cae2f905ec59665c"},
{"internal/host/claimretire_transport.go", "retirementAcknowledgement", "nonrecord", "sel:Put", "5c09795fbe9b952436d16bcd58e846ac27e9dbcf959fcf1cd6c432af0f3ee416"},
{"internal/host/errship.go", "errshipFlush", "nonrecord", "sel:Put", "82831c08563eab44de07c1c69752555eafe60c70dbf9521d9a4711bc5c104a6b"},
{"internal/host/fleetbind.go", "WriteFleetBinding", "nonrecord", "sel:Put", "44ab984c2c9b30abcc938132118f61a6922ccc0cd40ed2b6cb61c7654aa0a1c3"},
diff --git a/internal/registry/remote.go b/internal/registry/remote.go
index 04a5c6b1d..46f507315 100644
--- a/internal/registry/remote.go
+++ b/internal/registry/remote.go
@@ -66,6 +66,7 @@ func OpenRemote(path string, input io.WriteCloser, output io.ReadCloser, wait fu
// OpenRemoteReconnect reads service-controlled answers, not an authority boundary.
// Failed or cancelled mutations are never retried; the next call reconnects.
+// Canceled does not mean not applied: cancellation can race a committed mutation.
func OpenRemoteReconnect(path string, connect func() (io.WriteCloser, io.ReadCloser, func() error, error)) (*Registry, error) {
c := &remoteClient{connect: connect}
if err := c.reconnect(context.Background()); err != nil {
diff --git a/internal/release/store.go b/internal/release/store.go
index aef07aeac..1c8af98bf 100644
--- a/internal/release/store.go
+++ b/internal/release/store.go
@@ -22,9 +22,9 @@ import (
"time"
"github.com/aws/aws-sdk-go-v2/aws"
- awsconfig "github.com/aws/aws-sdk-go-v2/config"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go"
+ "github.com/carlosframework/platform/internal/rootaws"
"github.com/carlosframework/platform/internal/safefile"
"github.com/carlosframework/platform/internal/s3compat"
@@ -671,13 +671,14 @@ type S3Store struct {
Bucket string
}
-// NewS3Store builds a store on the standard AWS credential/region chain.
+// NewS3Store uses the standard AWS chain for non-root callers. Root never loads
+// shared profiles; rootaws admits only safe static off-cloud credentials files.
// Everything that makes a non-AWS store work — CARLOS_S3_ENDPOINT, path-style
// addressing, when-required checksums, and any per-provider quirk — now lives
// in internal/s3compat, so this store and the hibernation lease cannot drift
// apart against the same bucket. See that package for the quirk table.
func NewS3Store(ctx context.Context, bucket string) (*S3Store, error) {
- cfg, err := awsconfig.LoadDefaultConfig(ctx)
+ cfg, err := rootaws.LoadDefaultConfig(ctx)
if err != nil {
return nil, fmt.Errorf("s3store: load aws config: %w", err)
}
diff --git a/internal/rootaws/config.go b/internal/rootaws/config.go
new file mode 100644
index 000000000..c8229d77e
--- /dev/null
+++ b/internal/rootaws/config.go
@@ -0,0 +1,100 @@
+// Package rootaws keeps root's AWS clients away from executable shared profiles.
+package rootaws
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/credentials"
+)
+
+const credentialsPath = "/etc/carlos/credentials"
+
+// OffcloudCredentials returns the validated root file credentials only when
+// complete environment keys do not take precedence. It never contacts IMDS.
+func OffcloudCredentials() (aws.Credentials, error) {
+ return offcloudCredentials(credentialsPath)
+}
+
+func offcloudCredentials(path string) (aws.Credentials, error) {
+ if envAccessKey() != "" && envSecretKey() != "" {
+ return aws.Credentials{}, nil
+ }
+ creds, err := readStaticCredentials(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return aws.Credentials{}, nil
+ }
+ return creds, err
+}
+
+// LoadDefaultConfig preserves the SDK chain for non-root callers. Root disables
+// both shared-file lists, including paths selected through HOME or AWS_* vars.
+func LoadDefaultConfig(ctx context.Context, opts ...func(*awsconfig.LoadOptions) error) (aws.Config, error) {
+ if os.Geteuid() != 0 {
+ return awsconfig.LoadDefaultConfig(ctx, opts...)
+ }
+ return loadRootConfig(ctx, credentialsPath, opts...)
+}
+
+func loadRootConfig(ctx context.Context, path string, opts ...func(*awsconfig.LoadOptions) error) (aws.Config, error) {
+ // The SDK cannot ignore an environment profile per call. Refuse it rather
+ // than mutate process-wide environment while other goroutines may read it.
+ if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
+ err := fmt.Errorf("root AWS clients do not support AWS_PROFILE or AWS_DEFAULT_PROFILE")
+ log.Printf("ALERT: %v", err)
+ return aws.Config{}, err
+ }
+ // Complete environment keys are used as given and the fallback file is not
+ // opened, so a file nobody uses cannot make loading fail.
+ access, secret := envAccessKey(), envSecretKey()
+ envKeys := access != "" && secret != ""
+ var static aws.Credentials
+ var refused error
+ if !envKeys {
+ var err error
+ static, err = readStaticCredentials(path)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ log.Printf("ALERT: refusing unsafe offcloud credentials %s: %v", path, err)
+ refused = fmt.Errorf("root AWS credentials: %w", err)
+ }
+ if err != nil {
+ static = aws.Credentials{}
+ }
+ }
+ // Append last so callers cannot restore file loading or select a profile.
+ opts = append(opts, awsconfig.WithSharedConfigFiles([]string{}),
+ awsconfig.WithSharedCredentialsFiles([]string{}), awsconfig.WithSharedConfigProfile(""))
+ switch {
+ case refused != nil:
+ // Opening a store must not abort adoption before it uses AWS. Refuse
+ // retrieval instead, without falling through to another identity.
+ opts = append(opts, awsconfig.WithCredentialsProvider(aws.CredentialsProviderFunc(
+ func(context.Context) (aws.Credentials, error) { return aws.Credentials{}, refused })))
+ case envKeys:
+ opts = append(opts, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
+ access, secret, os.Getenv("AWS_SESSION_TOKEN"))))
+ case static.AccessKeyID != "":
+ opts = append(opts, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
+ static.AccessKeyID, static.SecretAccessKey, static.SessionToken)))
+ }
+ return awsconfig.LoadDefaultConfig(ctx, opts...)
+}
+
+func envAccessKey() string {
+ if v := os.Getenv("AWS_ACCESS_KEY_ID"); v != "" {
+ return v
+ }
+ return os.Getenv("AWS_ACCESS_KEY")
+}
+
+func envSecretKey() string {
+ if v := os.Getenv("AWS_SECRET_ACCESS_KEY"); v != "" {
+ return v
+ }
+ return os.Getenv("AWS_SECRET_KEY")
+}
diff --git a/internal/rootaws/config_test.go b/internal/rootaws/config_test.go
new file mode 100644
index 000000000..f54defb37
--- /dev/null
+++ b/internal/rootaws/config_test.go
@@ -0,0 +1,297 @@
+package rootaws
+
+import (
+ "bytes"
+ "context"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/credentials"
+)
+
+func TestParseStaticCredentials(t *testing.T) {
+ valid := "# comment\n[default]\naws_access_key_id = ACCESS\naws_secret_access_key = SECRET/+==\naws_session_token = TOKEN/+==\n"
+ got, err := ParseStaticCredentials([]byte(valid))
+ if err != nil || got.AccessKeyID != "ACCESS" || got.SecretAccessKey != "SECRET/+==" || got.SessionToken != "TOKEN/+==" {
+ t.Fatalf("static parse failed: %v", err)
+ }
+ for name, body := range map[string]string{
+ "process": valid + "credential_process = touch /never-run\n",
+ "unknown": valid + "region = eu-west-1\n",
+ "other-profile": valid + "[other]\ncredential_process = bad\n",
+ "duplicate-profile": valid + "[default]\n",
+ "duplicate-key": valid + "aws_access_key_id = SECOND\n",
+ "missing-secret": "[default]\naws_access_key_id=ACCESS\n",
+ "no-profile": "aws_access_key_id=ACCESS\naws_secret_access_key=SECRET\n",
+ "empty": "",
+ "nul": valid + "\x00",
+ "oversize": strings.Repeat("#", MaxCredentialsSize+1),
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := ParseStaticCredentials([]byte(body)); err == nil {
+ t.Fatal("accepted invalid static credentials")
+ }
+ })
+ }
+}
+
+func TestParseStaticCredentialsSDKSemantics(t *testing.T) {
+ parse := func(access, secret string) (string, string, error) {
+ got, err := ParseStaticCredentials([]byte("[default]\naws_access_key_id = " + access + "\naws_secret_access_key = " + secret + "\n"))
+ return got.AccessKeyID, got.SecretAccessKey, err
+ }
+ for name, tc := range map[string]struct{ access, secret, wantAccess, wantSecret string }{
+ "embedded-hash": {"ACC#ESS", "SEC#RET", "ACC#ESS", "SEC#RET"},
+ "embedded-semicolon": {"ACC;ESS", "SEC;RET", "ACC;ESS", "SEC;RET"},
+ "space-hash-comment": {"ACCESS # note", "SECRET ; note", "ACCESS", "SECRET"},
+ "tab-comment": {"ACCESS\t# note", "SECRET\t; note", "ACCESS", "SECRET"},
+ "double-quotes": {`"ACCESS"`, `"SEC#RET"`, "ACCESS", "SEC#RET"},
+ "single-quotes": {`'ACCESS'`, `'SEC;RET'`, "ACCESS", "SEC;RET"},
+ "quotes-and-comments": {`"ACCESS" # note`, `'SECRET' ; note`, "ACCESS", "SECRET"},
+ "one-pair-only": {`""ACCESS""`, `'"SECRET"'`, `"ACCESS"`, `"SECRET"`},
+ "mismatched-quotes": {`"ACCESS'`, `'SECRET`, `"ACCESS'`, `'SECRET`},
+ "lone-quote": {`"`, `'`, `"`, `'`},
+ } {
+ t.Run(name, func(t *testing.T) {
+ access, secret, err := parse(tc.access, tc.secret)
+ if err != nil || access != tc.wantAccess || secret != tc.wantSecret {
+ t.Fatalf("parsed %q %q, %v", access, secret, err)
+ }
+ })
+ }
+ for name, body := range map[string]string{
+ "empty-double-quotes": "[default]\naws_access_key_id = \"\"\naws_secret_access_key = SECRET\n",
+ "empty-single-quotes": "[default]\naws_access_key_id = ACCESS\naws_secret_access_key = ''\n",
+ "comment-only-value": "[default]\naws_access_key_id = # note\naws_secret_access_key = SECRET\n",
+ "quoted-spaces": "[default]\naws_access_key_id = \"AC CESS\"\naws_secret_access_key = SECRET\n",
+ "process-with-quotes": "[default]\naws_access_key_id = ACCESS\naws_secret_access_key = SECRET\n\"credential_process\" = bad\n",
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := ParseStaticCredentials([]byte(body)); err == nil {
+ t.Fatal("accepted invalid static credentials")
+ }
+ })
+ }
+}
+
+func TestRootConfigEnvironmentKeysIgnoreMalformedFile(t *testing.T) {
+ for _, token := range []string{"", "ENV_TOKEN"} {
+ t.Run("token="+token, func(t *testing.T) {
+ isolatedAWSEnv(t)
+ t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET")
+ t.Setenv("AWS_SESSION_TOKEN", token)
+ path := filepath.Join(t.TempDir(), "credentials")
+ if err := os.WriteFile(path, []byte("[default]\ncredential_process = touch /never-run\n[other]\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := loadRootConfig(context.Background(), path)
+ if err != nil {
+ t.Fatalf("malformed unused file made config fatal: %v", err)
+ }
+ got, err := cfg.Credentials.Retrieve(context.Background())
+ if err != nil || got.AccessKeyID != "ENV_ACCESS" || got.SecretAccessKey != "ENV_SECRET" || got.SessionToken != token {
+ t.Fatalf("environment credentials failed: %v", err)
+ }
+ })
+ }
+}
+
+func TestRootConfigIncompleteEnvironmentKeysStillValidateFile(t *testing.T) {
+ isolatedAWSEnv(t)
+ t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS")
+ path := filepath.Join(t.TempDir(), "credentials")
+ if err := os.WriteFile(path, []byte("[default]\ncredential_process = bad\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := loadRootConfig(context.Background(), path)
+ if err != nil {
+ t.Fatalf("unused unsafe credentials made config loading fatal: %v", err)
+ }
+ if _, err := cfg.Credentials.Retrieve(context.Background()); err == nil {
+ t.Fatal("malformed file accepted without a complete environment key pair")
+ }
+}
+
+func TestRootConfigEnvironmentKeysBeatCallerCredentials(t *testing.T) {
+ isolatedAWSEnv(t)
+ t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET")
+ caller := awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("CALLER", "CALLER_SECRET", ""))
+ cfg, err := loadRootConfig(context.Background(), filepath.Join(t.TempDir(), "absent"), caller)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := cfg.Credentials.Retrieve(context.Background())
+ if err != nil || got.AccessKeyID != "ENV_ACCESS" || got.SecretAccessKey != "ENV_SECRET" {
+ t.Fatalf("caller credentials overrode the environment: %v", err)
+ }
+}
+
+func isolatedAWSEnv(t *testing.T) {
+ t.Helper()
+ for _, key := range []string{"AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_ACCESS_KEY_ID", "AWS_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY", "AWS_SECRET_KEY", "AWS_SESSION_TOKEN", "AWS_WEB_IDENTITY_TOKEN_FILE", "AWS_ROLE_ARN", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_CONTAINER_CREDENTIALS_FULL_URI"} {
+ t.Setenv(key, "")
+ }
+ t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
+ t.Setenv("AWS_REGION", "eu-west-1")
+}
+
+func TestRootConfigNeverLoadsSharedFiles(t *testing.T) {
+ for _, selector := range []string{"HOME", "AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "options", "profile"} {
+ t.Run(selector, func(t *testing.T) {
+ isolatedAWSEnv(t)
+ dir := t.TempDir()
+ t.Setenv("HOME", dir)
+ t.Setenv("AWS_CONFIG_FILE", filepath.Join(dir, "missing-config"))
+ t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(dir, "missing-keys"))
+ marker := filepath.Join(dir, "executed")
+ // If the SDK ever selects this profile, retrieval executes touch.
+ body := "[default]\ncredential_process = touch " + marker + "\n"
+ bad := filepath.Join(dir, "untrusted")
+ if err := os.WriteFile(bad, []byte(body), 0600); err != nil {
+ t.Fatal(err)
+ }
+ var opts []func(*awsconfig.LoadOptions) error
+ switch selector {
+ case "HOME":
+ if err := os.Mkdir(filepath.Join(dir, ".aws"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"config", "credentials"} {
+ if err := os.WriteFile(filepath.Join(dir, ".aws", name), []byte(body), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ t.Setenv("AWS_CONFIG_FILE", "")
+ t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "")
+ case "options":
+ opts = append(opts, awsconfig.WithSharedConfigFiles([]string{bad}), awsconfig.WithSharedCredentialsFiles([]string{bad}))
+ case "profile":
+ t.Setenv("AWS_PROFILE", "default")
+ t.Setenv("AWS_CONFIG_FILE", bad)
+ default:
+ t.Setenv(selector, bad)
+ }
+ cfg, err := loadRootConfig(context.Background(), filepath.Join(dir, "absent"), opts...)
+ if err == nil {
+ if _, err := cfg.Credentials.Retrieve(context.Background()); err == nil {
+ t.Fatal("unexpected credentials without an allowed source")
+ }
+ } else if selector != "profile" {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(marker); !os.IsNotExist(err) {
+ t.Fatal("credential_process executed")
+ }
+ })
+ }
+}
+
+func TestRootConfigEnvironmentCredentials(t *testing.T) {
+ isolatedAWSEnv(t)
+ t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET")
+ t.Setenv("AWS_SESSION_TOKEN", "ENV_TOKEN")
+ cfg, err := loadRootConfig(context.Background(), filepath.Join(t.TempDir(), "absent"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := cfg.Credentials.Retrieve(context.Background())
+ if err != nil || got.AccessKeyID != "ENV_ACCESS" || got.SecretAccessKey != "ENV_SECRET" || got.SessionToken != "ENV_TOKEN" {
+ t.Fatalf("environment credentials failed: %v", err)
+ }
+}
+
+func TestRootConfigRefusesProfilesExplicitly(t *testing.T) {
+ for _, name := range []string{"AWS_PROFILE", "AWS_DEFAULT_PROFILE"} {
+ t.Run(name, func(t *testing.T) {
+ isolatedAWSEnv(t)
+ t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET")
+ t.Setenv(name, "default")
+ var logs bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&logs)
+ defer log.SetOutput(old)
+ _, err := loadRootConfig(context.Background(), filepath.Join(t.TempDir(), "absent"))
+ if err == nil || !strings.Contains(err.Error(), "do not support") || !strings.Contains(logs.String(), "ALERT:") {
+ t.Fatalf("profile not explicitly refused with an alert: %v", err)
+ }
+ })
+ }
+}
+
+func TestNonRootConfigKeepsSharedCredentials(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("requires non-root to exercise unchanged SDK behavior")
+ }
+ isolatedAWSEnv(t)
+ dir := t.TempDir()
+ path := filepath.Join(dir, "credentials")
+ if err := os.WriteFile(path, []byte("[default]\naws_access_key_id=SHARED\naws_secret_access_key=SECRET\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("AWS_SHARED_CREDENTIALS_FILE", path)
+ t.Setenv("AWS_CONFIG_FILE", filepath.Join(dir, "absent"))
+ cfg, err := LoadDefaultConfig(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := cfg.Credentials.Retrieve(context.Background())
+ if err != nil || got.AccessKeyID != "SHARED" {
+ t.Fatalf("non-root shared credentials changed: %v", err)
+ }
+}
+
+func TestRootConfigUnsafeFileRefusesOnUseWithoutFallback(t *testing.T) {
+ isolatedAWSEnv(t)
+ path := filepath.Join(t.TempDir(), "credentials")
+ if err := os.WriteFile(path, []byte("[default]\ncredential_process=bad\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ fallback := credentials.NewStaticCredentialsProvider("OTHER_IDENTITY", "SECRET", "")
+ cfg, err := loadRootConfig(context.Background(), path, awsconfig.WithCredentialsProvider(fallback))
+ if err != nil {
+ t.Fatalf("opening an unused store would abort adoption: %v", err)
+ }
+ if got, err := cfg.Credentials.Retrieve(context.Background()); err == nil || got.AccessKeyID != "" {
+ t.Fatal("unsafe file fell through to another identity")
+ }
+}
+
+func TestRootCredentialsOffcloudSelection(t *testing.T) {
+ isolatedAWSEnv(t)
+ path := filepath.Join(t.TempDir(), "credentials")
+ got, err := offcloudCredentials(path)
+ if err != nil || got.HasKeys() {
+ t.Fatalf("absent file: credentials=%v err=%v", got.HasKeys(), err)
+ }
+ if err := os.WriteFile(path, []byte("[default]\ncredential_process=bad\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := offcloudCredentials(path); err == nil {
+ t.Fatal("unsafe file was accepted")
+ }
+ t.Run("incomplete environment keys", func(t *testing.T) {
+ t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS")
+ if _, err := offcloudCredentials(path); err == nil {
+ t.Fatal("incomplete environment keys bypassed the unsafe file")
+ }
+ })
+ for _, names := range [][2]string{{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"}, {"AWS_ACCESS_KEY", "AWS_SECRET_KEY"}} {
+ t.Run(names[0], func(t *testing.T) {
+ t.Setenv(names[0], "ENV_ACCESS")
+ t.Setenv(names[1], "ENV_SECRET")
+ t.Setenv("AWS_SESSION_TOKEN", "ENV_TOKEN")
+ got, err := offcloudCredentials(path)
+ if err != nil || got.HasKeys() || got.SessionToken != "" {
+ t.Fatalf("environment keys must bypass file and leave static fields unset: %v", err)
+ }
+ })
+ }
+}
diff --git a/internal/rootaws/credentials.go b/internal/rootaws/credentials.go
new file mode 100644
index 000000000..d5993a946
--- /dev/null
+++ b/internal/rootaws/credentials.go
@@ -0,0 +1,71 @@
+package rootaws
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+)
+
+const MaxCredentialsSize = 64 << 10
+
+// ParseStaticCredentials accepts only one default profile with literal keys.
+// Errors never include credential values or executable configuration text.
+func ParseStaticCredentials(body []byte) (aws.Credentials, error) {
+ var empty aws.Credentials
+ if len(body) > MaxCredentialsSize || bytes.IndexByte(body, 0) >= 0 {
+ return empty, fmt.Errorf("invalid credentials size or encoding")
+ }
+ profile := false
+ values := make(map[string]string)
+ for _, line := range strings.Split(string(body), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
+ continue
+ }
+ if strings.HasPrefix(line, "[") {
+ if line != "[default]" || profile {
+ return empty, fmt.Errorf("credentials require one default profile")
+ }
+ profile = true
+ continue
+ }
+ // Same order as the SDK: cut the inline comment, then trim, then
+ // remove one matching pair of quotes.
+ key, value, found := strings.Cut(trimInlineComment(line), "=")
+ key, value = strings.TrimSpace(key), unquote(strings.TrimSpace(value))
+ if !profile || !found || value == "" {
+ return empty, fmt.Errorf("invalid static credentials entry")
+ }
+ switch key {
+ case "aws_access_key_id", "aws_secret_access_key", "aws_session_token":
+ default:
+ return empty, fmt.Errorf("unsupported credentials key")
+ }
+ if _, exists := values[key]; exists || strings.ContainsAny(value, "\r\t ") {
+ return empty, fmt.Errorf("duplicate or invalid static credentials entry")
+ }
+ values[key] = value
+ }
+ if values["aws_access_key_id"] == "" || values["aws_secret_access_key"] == "" {
+ return empty, fmt.Errorf("credentials require access and secret keys")
+ }
+ return aws.Credentials{AccessKeyID: values["aws_access_key_id"], SecretAccessKey: values["aws_secret_access_key"], SessionToken: values["aws_session_token"], Source: "CarlosStaticCredentials"}, nil
+}
+
+// trimInlineComment matches the SDK's property comments: only a # or ; after
+// a space or tab starts one, so values may contain either character.
+func trimInlineComment(s string) string {
+ for _, marker := range []string{" #", " ;", "\t#", "\t;"} {
+ s, _, _ = strings.Cut(s, marker)
+ }
+ return s
+}
+
+func unquote(s string) string {
+ if len(s) >= 2 && (s[0] == '"' && s[len(s)-1] == '"' || s[0] == '\'' && s[len(s)-1] == '\'') {
+ return s[1 : len(s)-1]
+ }
+ return s
+}
diff --git a/internal/rootaws/credentials_linux_test.go b/internal/rootaws/credentials_linux_test.go
new file mode 100644
index 000000000..80dcb739e
--- /dev/null
+++ b/internal/rootaws/credentials_linux_test.go
@@ -0,0 +1,138 @@
+package rootaws
+
+import (
+ "bytes"
+ "context"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "golang.org/x/sys/unix"
+)
+
+func TestRootCredentialsRejectUnsafeFiles(t *testing.T) {
+ for _, kind := range []string{"symlink", "dangling", "hardlink", "fifo", "directory", "service-owner", "group-write", "other-write", "process"} {
+ t.Run(kind, func(t *testing.T) {
+ isolatedAWSEnv(t)
+ dir := t.TempDir()
+ path := filepath.Join(dir, "credentials")
+ target := filepath.Join(dir, "target")
+ body := []byte("[default]\naws_access_key_id=ACCESS\naws_secret_access_key=SECRET\n")
+ if err := os.WriteFile(target, body, 0600); err != nil {
+ t.Fatal(err)
+ }
+ var err error
+ switch kind {
+ case "symlink":
+ err = os.Symlink(target, path)
+ case "dangling":
+ err = os.Symlink(filepath.Join(dir, "missing"), path)
+ case "hardlink":
+ err = os.Link(target, path)
+ case "fifo":
+ err = unix.Mkfifo(path, 0600)
+ case "directory":
+ err = os.Mkdir(path, 0700)
+ default:
+ if os.Geteuid() != 0 && kind != "service-owner" {
+ t.Skip("requires root-owned fixture to isolate mode and content checks")
+ }
+ if kind == "process" {
+ body = append(body, []byte("credential_process=bad\n")...)
+ }
+ err = os.WriteFile(path, body, 0600)
+ if err == nil {
+ switch kind {
+ case "service-owner":
+ if os.Geteuid() == 0 {
+ err = os.Chown(path, 65534, 65534)
+ }
+ case "group-write":
+ err = os.Chmod(path, 0660)
+ case "other-write":
+ err = os.Chmod(path, 0602)
+ }
+ }
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ var logs bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&logs)
+ defer log.SetOutput(old)
+ cfg, err := loadRootConfig(context.Background(), path)
+ if err != nil {
+ t.Fatalf("unused unsafe credentials made config loading fatal: %v", err)
+ }
+ if _, err := cfg.Credentials.Retrieve(context.Background()); err == nil {
+ t.Fatal("unsafe file accepted")
+ }
+ if !strings.Contains(logs.String(), "ALERT:") {
+ t.Fatal("unsafe file refusal did not alert")
+ }
+ })
+ }
+}
+
+func TestRootCredentialsEnvironmentKeysIgnoreServiceOwnedFile(t *testing.T) {
+ isolatedAWSEnv(t)
+ t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET")
+ path := filepath.Join(t.TempDir(), "credentials")
+ if err := os.WriteFile(path, []byte("[default]\naws_access_key_id=FILE\naws_secret_access_key=SECRET\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if os.Geteuid() == 0 {
+ if err := os.Chown(path, 65534, 65534); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // Without environment keys the same file is refused, so the file is unsafe.
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "")
+ unsafeConfig, err := loadRootConfig(context.Background(), path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := unsafeConfig.Credentials.Retrieve(context.Background()); err == nil {
+ t.Fatal("service-owned fixture was not unsafe")
+ }
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET")
+ cfg, err := loadRootConfig(context.Background(), path)
+ if err != nil {
+ t.Fatalf("service-owned unused file made config fatal: %v", err)
+ }
+ got, err := cfg.Credentials.Retrieve(context.Background())
+ if err != nil || got.AccessKeyID != "ENV_ACCESS" || got.SecretAccessKey != "ENV_SECRET" {
+ t.Fatalf("environment credentials failed: %v", err)
+ }
+}
+
+func TestRootCredentialsStaticProvider(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("requires root-owned credentials fixture")
+ }
+ isolatedAWSEnv(t)
+ path := filepath.Join(t.TempDir(), "credentials")
+ if err := os.WriteFile(path, []byte("[default]\naws_access_key_id=STATIC\naws_secret_access_key=SECRET\naws_session_token=TOKEN\n"), 0640); err != nil {
+ t.Fatal(err)
+ }
+ fileCreds, err := offcloudCredentials(path)
+ if err != nil || fileCreds.AccessKeyID != "STATIC" || fileCreds.SecretAccessKey != "SECRET" || fileCreds.SessionToken != "TOKEN" {
+ t.Fatalf("off-cloud file credentials failed: %v", err)
+ }
+ cfg, err := loadRootConfig(context.Background(), path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The provider holds the parsed snapshot and never asks the SDK to reread it.
+ if err := os.WriteFile(path, []byte("[default]\ncredential_process=bad\n"), 0640); err != nil {
+ t.Fatal(err)
+ }
+ got, err := cfg.Credentials.Retrieve(context.Background())
+ if err != nil || got.AccessKeyID != "STATIC" || got.SessionToken != "TOKEN" {
+ t.Fatalf("static snapshot failed: %v", err)
+ }
+}
diff --git a/internal/rootaws/credentials_other.go b/internal/rootaws/credentials_other.go
new file mode 100644
index 000000000..495e483ff
--- /dev/null
+++ b/internal/rootaws/credentials_other.go
@@ -0,0 +1,13 @@
+//go:build !linux && !darwin
+
+package rootaws
+
+import (
+ "fmt"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+)
+
+func readStaticCredentials(string) (aws.Credentials, error) {
+ return aws.Credentials{}, fmt.Errorf("root static credentials require Linux or Darwin")
+}
diff --git a/internal/rootaws/credentials_unix.go b/internal/rootaws/credentials_unix.go
new file mode 100644
index 000000000..df2bd6dc7
--- /dev/null
+++ b/internal/rootaws/credentials_unix.go
@@ -0,0 +1,31 @@
+//go:build linux || darwin
+
+package rootaws
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "golang.org/x/sys/unix"
+)
+
+func readStaticCredentials(path string) (aws.Credentials, error) {
+ f, err := openCredentials(path)
+ if err != nil {
+ return aws.Credentials{}, err
+ }
+ defer f.Close()
+ var st unix.Stat_t
+ if err := unix.Fstat(int(f.Fd()), &st); err != nil {
+ return aws.Credentials{}, err
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 || st.Uid != 0 || st.Mode&0o022 != 0 {
+ return aws.Credentials{}, fmt.Errorf("credentials must be root-owned, single-link regular and not group- or other-writable")
+ }
+ body, err := io.ReadAll(io.LimitReader(f, MaxCredentialsSize+1))
+ if err != nil {
+ return aws.Credentials{}, err
+ }
+ return ParseStaticCredentials(body)
+}
diff --git a/internal/rootaws/environment.go b/internal/rootaws/environment.go
new file mode 100644
index 000000000..343ea793b
--- /dev/null
+++ b/internal/rootaws/environment.go
@@ -0,0 +1,97 @@
+package rootaws
+
+import (
+ "log"
+ "os"
+ "strings"
+)
+
+// A child of a device cannot exist, even if a service user creates directories.
+const disabledSharedFile = "/dev/null/carlos-root-aws"
+
+// rootScrubbedEnvironment names every variable that selects a file, a profile,
+// an endpoint or a trust store the SDK would use on root's behalf. Root's own
+// credentials come from the environment keys, the instance profile, or the
+// validated off-cloud file, never from these.
+var rootScrubbedEnvironment = []string{
+ "AWS_CONFIG_FILE",
+ "AWS_SHARED_CREDENTIALS_FILE",
+ "AWS_PROFILE",
+ "AWS_DEFAULT_PROFILE",
+ "AWS_SDK_LOAD_CONFIG",
+ "AWS_CA_BUNDLE",
+ "AWS_WEB_IDENTITY_TOKEN_FILE",
+ "AWS_ROLE_ARN",
+ "AWS_ROLE_SESSION_NAME",
+ "AWS_CONTAINER_CREDENTIALS_FULL_URI",
+ "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
+ "AWS_CONTAINER_AUTHORIZATION_TOKEN",
+ "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE",
+ "AWS_ENDPOINT_URL",
+ "AWS_EC2_METADATA_SERVICE_ENDPOINT",
+ "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE",
+ // Go's TLS trust store reads these unvalidated; AWS transports use it.
+ "SSL_CERT_FILE",
+ "SSL_CERT_DIR",
+}
+
+// Service-specific endpoint overrides (AWS_ENDPOINT_URL_S3 and the rest)
+// are matched by prefix.
+const rootScrubbedEndpointPrefix = "AWS_ENDPOINT_URL_"
+
+// ScrubEnvironment must run before dispatch or goroutines in each executable.
+// It also protects SDK loads in dependencies that do not call this package.
+func ScrubEnvironment() {
+ scrubEnvironment(os.Geteuid())
+}
+
+func scrubEnvironment(euid int) {
+ if euid != 0 {
+ return
+ }
+ changed := false
+ keys := append([]string(nil), rootScrubbedEnvironment...)
+ for _, kv := range os.Environ() {
+ if key, _, _ := strings.Cut(kv, "="); strings.HasPrefix(key, rootScrubbedEndpointPrefix) {
+ keys = append(keys, key)
+ }
+ }
+ for _, key := range keys {
+ if _, set := os.LookupEnv(key); set {
+ changed = true
+ }
+ _ = os.Unsetenv(key)
+ }
+ // A proxy may be needed off-cloud, so it stays, but it never answers for
+ // the instance metadata service: that is where root's EC2 credentials
+ // come from.
+ for _, key := range []string{"NO_PROXY", "no_proxy"} {
+ _ = os.Setenv(key, withIMDSBypass(os.Getenv(key)))
+ }
+ // Root never reads shared config, not even under its own HOME: a
+ // root-owned HOME can still hold a .aws the service user can write.
+ _ = os.Setenv("AWS_CONFIG_FILE", disabledSharedFile)
+ _ = os.Setenv("AWS_SHARED_CREDENTIALS_FILE", disabledSharedFile)
+ if changed {
+ log.Print("ALERT: root AWS file, profile, endpoint and trust-store environment overrides ignored")
+ }
+}
+
+// imdsHosts are the IPv4 and IPv6 instance metadata addresses.
+var imdsHosts = []string{"169.254.169.254", "fd00:ec2::254"}
+
+func withIMDSBypass(noProxy string) string {
+ entries := strings.FieldsFunc(noProxy, func(r rune) bool { return r == ',' })
+ for _, host := range imdsHosts {
+ found := false
+ for _, entry := range entries {
+ if strings.TrimSpace(entry) == host {
+ found = true
+ }
+ }
+ if !found {
+ entries = append(entries, host)
+ }
+ }
+ return strings.Join(entries, ",")
+}
diff --git a/internal/rootaws/environment_test.go b/internal/rootaws/environment_test.go
new file mode 100644
index 000000000..3d4dc9774
--- /dev/null
+++ b/internal/rootaws/environment_test.go
@@ -0,0 +1,151 @@
+package rootaws
+
+import (
+ "bytes"
+ "context"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+)
+
+func TestScrubEnvironment(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ euid int
+ }{
+ {"root", 0},
+ {"non-root", 1001},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, key := range rootScrubbedEnvironment {
+ t.Setenv(key, "untrusted")
+ }
+ t.Setenv("AWS_ENDPOINT_URL_S3", "untrusted")
+ t.Setenv("AWS_ENDPOINT_URL_STS", "untrusted")
+ t.Setenv("AWS_ACCESS_KEY_ID", "KEEP")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "KEEP_SECRET")
+ t.Setenv("AWS_SESSION_TOKEN", "KEEP_TOKEN")
+ var logs bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&logs)
+ defer log.SetOutput(old)
+ scrubEnvironment(tc.euid)
+ for _, key := range rootScrubbedEnvironment {
+ got, set := os.LookupEnv(key)
+ want := "untrusted"
+ if tc.euid == 0 {
+ want = ""
+ if key == "AWS_CONFIG_FILE" || key == "AWS_SHARED_CREDENTIALS_FILE" {
+ want = disabledSharedFile
+ }
+ }
+ if got != want || set != (want != "") {
+ t.Errorf("%s = %q, set=%v; want %q", key, got, set, want)
+ }
+ }
+ for _, key := range []string{"AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL_STS"} {
+ if _, set := os.LookupEnv(key); set != (tc.euid != 0) {
+ t.Errorf("%s set=%v for euid %d", key, set, tc.euid)
+ }
+ }
+ wantLogs := 0
+ if tc.euid == 0 {
+ wantLogs = 1
+ }
+ if strings.Count(logs.String(), "ALERT:") != wantLogs {
+ t.Fatalf("alert count: %q", logs.String())
+ }
+ if os.Getenv("AWS_ACCESS_KEY_ID") != "KEEP" || os.Getenv("AWS_SECRET_ACCESS_KEY") != "KEEP_SECRET" || os.Getenv("AWS_SESSION_TOKEN") != "KEEP_TOKEN" {
+ t.Fatal("environment credentials changed")
+ }
+ })
+ }
+}
+
+func TestScrubEnvironmentDisablesSharedFilesWithNothingSet(t *testing.T) {
+ for _, key := range rootScrubbedEnvironment {
+ t.Setenv(key, "")
+ os.Unsetenv(key)
+ }
+ var logs bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&logs)
+ defer log.SetOutput(old)
+ scrubEnvironment(0)
+ if os.Getenv("AWS_CONFIG_FILE") != disabledSharedFile || os.Getenv("AWS_SHARED_CREDENTIALS_FILE") != disabledSharedFile {
+ t.Fatal("shared files not disabled for root")
+ }
+ if logs.Len() != 0 {
+ t.Fatalf("alert without overrides: %q", logs.String())
+ }
+}
+
+func TestScrubEnvironmentBlocksDependencySharedConfig(t *testing.T) {
+ isolatedAWSEnv(t)
+ dir := t.TempDir()
+ marker := filepath.Join(dir, "executed")
+ // The planted config sits under HOME, where the SDK looks by default, and
+ // is also named explicitly.
+ if err := os.Mkdir(filepath.Join(dir, ".aws"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(dir, ".aws", "config")
+ if err := os.WriteFile(path, []byte("[default]\ncredential_process = touch "+marker+"\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, ".aws", "credentials"), []byte("[default]\ncredential_process = touch "+marker+"\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("HOME", dir)
+ t.Setenv("AWS_CONFIG_FILE", path)
+ t.Setenv("AWS_SHARED_CREDENTIALS_FILE", path)
+ t.Setenv("AWS_PROFILE", "default")
+ t.Setenv("AWS_DEFAULT_PROFILE", "default")
+ t.Setenv("AWS_SDK_LOAD_CONFIG", "1")
+ scrubEnvironment(0)
+ // This deliberately bypasses rootaws, as a third-party SDK call would.
+ cfg, err := awsconfig.LoadDefaultConfig(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cfg.Credentials.Retrieve(context.Background()); err == nil {
+ t.Fatal("unexpected credentials")
+ }
+ if _, err := os.Stat(marker); !os.IsNotExist(err) {
+ t.Fatal("credential_process executed")
+ }
+ if _, err := os.Stat(disabledSharedFile); err == nil {
+ t.Fatal("disabled path exists")
+ }
+}
+
+func TestScrubEnvironmentKeepsProxyButBypassesIMDS(t *testing.T) {
+ for _, tc := range []struct{ in, want string }{
+ {"", "169.254.169.254,fd00:ec2::254"},
+ {"example.com", "example.com,169.254.169.254,fd00:ec2::254"},
+ {"169.254.169.254,example.com", "169.254.169.254,example.com,fd00:ec2::254"},
+ } {
+ t.Setenv("HTTP_PROXY", "http://proxy.example.com:3128")
+ t.Setenv("NO_PROXY", tc.in)
+ t.Setenv("no_proxy", tc.in)
+ scrubEnvironment(0)
+ if os.Getenv("HTTP_PROXY") != "http://proxy.example.com:3128" {
+ t.Fatal("proxy removed")
+ }
+ if got := os.Getenv("NO_PROXY"); got != tc.want {
+ t.Errorf("NO_PROXY(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ if got := os.Getenv("no_proxy"); got != tc.want {
+ t.Errorf("no_proxy(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+ t.Setenv("NO_PROXY", "")
+ scrubEnvironment(1001)
+ if os.Getenv("NO_PROXY") != "" {
+ t.Fatal("non-root NO_PROXY changed")
+ }
+}
diff --git a/internal/rootaws/open_darwin.go b/internal/rootaws/open_darwin.go
new file mode 100644
index 000000000..aa9519c67
--- /dev/null
+++ b/internal/rootaws/open_darwin.go
@@ -0,0 +1,11 @@
+package rootaws
+
+import (
+ "os"
+
+ "golang.org/x/sys/unix"
+)
+
+func openCredentials(path string) (*os.File, error) {
+ return os.OpenFile(path, os.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0)
+}
diff --git a/internal/rootaws/open_linux.go b/internal/rootaws/open_linux.go
new file mode 100644
index 000000000..2da6db489
--- /dev/null
+++ b/internal/rootaws/open_linux.go
@@ -0,0 +1,13 @@
+package rootaws
+
+import (
+ "os"
+
+ "github.com/carlosframework/platform/internal/safefile"
+)
+
+func openCredentials(path string) (*os.File, error) {
+ // safefile pins through O_PATH|O_NOFOLLOW, checks the inode and reopens
+ // that descriptor, rejecting links and special files before opening them.
+ return safefile.OpenFile(path, os.O_RDONLY, 0)
+}
diff --git a/internal/safefile/reap_linux.go b/internal/safefile/reap_linux.go
new file mode 100644
index 000000000..5d8670629
--- /dev/null
+++ b/internal/safefile/reap_linux.go
@@ -0,0 +1,179 @@
+package safefile
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+// Reap removes abandoned Linux writer staging directories below dir. Traversal
+// stays on the opened filesystem and never follows symlinks. Only private
+// directories owned by this euid, older than an hour, and containing at most
+// the writer's single regular data file are eligible. Missing roots are harmless.
+func Reap(dir string) error {
+ fd, err := openDir(dir, false, 0)
+ if errors.Is(err, unix.ENOENT) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ err = reapTree(os.NewFile(uintptr(fd), dir), time.Now().Add(-time.Hour), 0)
+ if err != nil {
+ return &os.PathError{Op: "reap staging", Path: dir, Err: err}
+ }
+ return nil
+}
+
+func reapStageName(name string) bool {
+ const prefix = ".carlos-write-"
+ if !strings.HasPrefix(name, prefix) || len(name) != len(prefix)+32 {
+ return false
+ }
+ for _, c := range name[len(prefix):] {
+ if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
+ return false
+ }
+ }
+ return true
+}
+
+func reapOpenAt(dir int, name string, flags uint64) (int, error) {
+ return unix.Openat2(dir, name, &unix.OpenHow{
+ Flags: flags | unix.O_CLOEXEC | unix.O_NOFOLLOW,
+ Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS |
+ unix.RESOLVE_NO_XDEV,
+ })
+}
+
+func reapSkip(err error) bool {
+ return errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ELOOP) ||
+ errors.Is(err, unix.EXDEV) || errors.Is(err, unix.ENOTDIR)
+}
+
+func reapTree(dir *os.File, cutoff time.Time, depth int) error {
+ remaining := 100000
+ return reapTreeBudget(dir, cutoff, depth, &remaining)
+}
+
+func reapTreeBudget(dir *os.File, cutoff time.Time, depth int, remaining *int) error {
+ defer dir.Close()
+ if depth > 128 {
+ return fmt.Errorf("staging traversal exceeds 128 directories")
+ }
+ var failures error
+ for {
+ entries, err := dir.ReadDir(128)
+ if err != nil && err != io.EOF {
+ return errors.Join(failures, err)
+ }
+ for _, entry := range entries {
+ if *remaining <= 0 {
+ return errors.Join(failures, fmt.Errorf("staging traversal exceeds 100000 entries"))
+ }
+ *remaining -= 1
+ // Type is only a fast rejection; openat2 validates the live entry.
+ if entry.Type() != 0 && !entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if strings.HasPrefix(name, ".carlos-write-") && !reapStageName(name) {
+ continue
+ }
+ fd, e := reapOpenAt(int(dir.Fd()), name, unix.O_RDONLY|unix.O_DIRECTORY)
+ if reapSkip(e) {
+ continue
+ }
+ if e != nil {
+ failures = errors.Join(failures, e)
+ continue
+ }
+ child := os.NewFile(uintptr(fd), name)
+ if reapStageName(name) {
+ e = reapStage(int(dir.Fd()), name, child, cutoff)
+ child.Close()
+ } else {
+ e = reapTreeBudget(child, cutoff, depth+1, remaining)
+ }
+ if e != nil {
+ failures = errors.Join(failures, e)
+ if *remaining <= 0 {
+ return failures
+ }
+ }
+ }
+ if err == io.EOF {
+ return failures
+ }
+ }
+}
+
+func reapOlder(st *unix.Stat_t, cutoff time.Time) bool {
+ return time.Unix(st.Mtim.Sec, st.Mtim.Nsec).Before(cutoff)
+}
+
+func reapSameEntry(parent int, name string, pinned *unix.Stat_t) bool {
+ var current unix.Stat_t
+ return unix.Fstatat(parent, name, &current, unix.AT_SYMLINK_NOFOLLOW) == nil &&
+ current.Dev == pinned.Dev && current.Ino == pinned.Ino &&
+ current.Mode == pinned.Mode && current.Uid == pinned.Uid &&
+ current.Nlink == pinned.Nlink
+}
+
+func reapStage(parent int, name string, dir *os.File, cutoff time.Time) error {
+ fd := int(dir.Fd())
+ var stage unix.Stat_t
+ if err := unix.Fstat(fd, &stage); err != nil {
+ return err
+ }
+ if stage.Uid != uint32(os.Geteuid()) || stage.Mode&0o777 != 0o700 ||
+ !reapOlder(&stage, cutoff) {
+ return nil
+ }
+ entries, err := dir.ReadDir(2)
+ if err != nil && err != io.EOF {
+ return err
+ }
+ if len(entries) > 1 || len(entries) == 1 && entries[0].Name() != "data" {
+ return nil
+ }
+ if len(entries) == 1 {
+ data, err := reapOpenAt(fd, "data", unix.O_PATH)
+ if reapSkip(err) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ defer unix.Close(data)
+ var st unix.Stat_t
+ if err := unix.Fstat(data, &st); err != nil {
+ return err
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 ||
+ st.Uid != uint32(os.Geteuid()) || !reapOlder(&st, cutoff) ||
+ !reapSameEntry(parent, name, &stage) || !reapSameEntry(fd, "data", &st) {
+ return nil
+ }
+ if err := unix.Unlinkat(fd, "data", 0); err != nil && !errors.Is(err, unix.ENOENT) {
+ return err
+ }
+ }
+ // Unlinkat never follows a replacement symlink. Recheck the parent entry
+ // before removing the empty directory, keeping all content deletion anchored.
+ // A substitution after this check can remove a different empty directory.
+ // That accepted race cannot follow a symlink or remove nonempty replacements.
+ if !reapSameEntry(parent, name, &stage) {
+ return nil
+ }
+ err = unix.Unlinkat(parent, name, unix.AT_REMOVEDIR)
+ if errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENOTEMPTY) || errors.Is(err, unix.ENOTDIR) {
+ return nil
+ }
+ return err
+}
diff --git a/internal/safefile/reap_linux_test.go b/internal/safefile/reap_linux_test.go
new file mode 100644
index 000000000..bd60238e3
--- /dev/null
+++ b/internal/safefile/reap_linux_test.go
@@ -0,0 +1,230 @@
+package safefile
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+func reapTestStage(t *testing.T, parent, name string, data bool) string {
+ t.Helper()
+ path := filepath.Join(parent, name)
+ if err := os.MkdirAll(path, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if data {
+ if err := os.WriteFile(filepath.Join(path, "data"), []byte("abandoned"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ reapTestAge(t, filepath.Join(path, "data"))
+ }
+ reapTestAge(t, path)
+ return path
+}
+
+func reapTestAge(t *testing.T, path string) {
+ t.Helper()
+ old := time.Now().Add(-2 * time.Hour)
+ if err := os.Chtimes(path, old, old); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestReapStaleWriterStages(t *testing.T) {
+ root := t.TempDir()
+ var removed, retained []string
+ for i, parent := range []string{root, filepath.Join(root, "versions", "v1"), filepath.Join(root, "staging")} {
+ removed = append(removed, reapTestStage(t, parent, fmt.Sprintf(".carlos-write-%032x", i+1), true))
+ }
+ removed = append(removed, reapTestStage(t, root, ".carlos-write-"+strings.Repeat("e", 32), false))
+ fresh := reapTestStage(t, root, ".carlos-write-"+strings.Repeat("f", 32), true)
+ now := time.Now()
+ if err := os.Chtimes(fresh, now, now); err != nil {
+ t.Fatal(err)
+ }
+ retained = append(retained, fresh)
+ active := reapTestStage(t, root, ".carlos-write-"+strings.Repeat("a", 32), true)
+ if err := os.Chtimes(filepath.Join(active, "data"), now, now); err != nil {
+ t.Fatal(err)
+ }
+ retained = append(retained, active)
+ for _, name := range []string{"ordinary", ".carlos-write-abc", ".carlos-write-" + strings.Repeat("A", 32), ".carlos-write-" + strings.Repeat("0", 33), ".carlos-write-" + strings.Repeat("g", 32)} {
+ retained = append(retained, reapTestStage(t, root, name, true))
+ }
+ if err := Reap(root); err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range removed {
+ if _, err := os.Lstat(path); !os.IsNotExist(err) {
+ t.Errorf("stale stage remains: %s: %v", path, err)
+ }
+ }
+ for _, path := range retained {
+ if _, err := os.Stat(filepath.Join(path, "data")); err != nil {
+ t.Errorf("retained stage changed: %s: %v", path, err)
+ }
+ }
+ if err := Reap(filepath.Join(root, "missing")); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestReapRefusesUnsafeStages(t *testing.T) {
+ for _, kind := range []string{"stage-symlink", "data-symlink", "hardlink", "fifo", "nested", "extra", "writable", "foreign-owner"} {
+ t.Run(kind, func(t *testing.T) {
+ root := t.TempDir()
+ outside := t.TempDir()
+ victim := filepath.Join(outside, "victim")
+ if err := os.WriteFile(victim, []byte("untouched"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ name := ".carlos-write-" + strings.Repeat("b", 32)
+ path := reapTestStage(t, root, name, false)
+ var err error
+ switch kind {
+ case "stage-symlink":
+ if err = os.Remove(path); err == nil {
+ err = os.Symlink(outside, path)
+ }
+ case "data-symlink":
+ err = os.Symlink(victim, filepath.Join(path, "data"))
+ case "hardlink":
+ err = os.Link(victim, filepath.Join(path, "data"))
+ case "fifo":
+ err = unix.Mkfifo(filepath.Join(path, "data"), 0o600)
+ case "nested":
+ reapTestStage(t, path, name, true)
+ case "extra":
+ err = os.WriteFile(filepath.Join(path, "unknown"), nil, 0o600)
+ case "writable":
+ err = os.Chmod(path, 0o777)
+ case "foreign-owner":
+ if os.Geteuid() != 0 {
+ t.Skip("requires root to make a foreign-owned stage")
+ }
+ err = os.Chown(path, 65534, 65534)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if kind != "stage-symlink" {
+ reapTestAge(t, path)
+ }
+ if err := Reap(root); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Lstat(path); err != nil {
+ t.Fatalf("unsafe stage removed: %v", err)
+ }
+ if kind == "nested" {
+ if _, err := os.Stat(filepath.Join(path, name, "data")); err != nil {
+ t.Fatalf("unexpected recursive deletion inside stage: %v", err)
+ }
+ }
+ if body, err := os.ReadFile(victim); err != nil || string(body) != "untouched" {
+ t.Fatalf("victim changed: %q %v", body, err)
+ }
+ })
+ }
+}
+
+func TestReapPinsRootAndRefusesSwappedStage(t *testing.T) {
+ root := t.TempDir()
+ parent := filepath.Join(root, "parent")
+ out := filepath.Join(root, "outside")
+ name := ".carlos-write-" + strings.Repeat("c", 32)
+ stage := reapTestStage(t, parent, name, true)
+ victim := reapTestStage(t, out, name, true)
+ dir, err := OpenDir(parent)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer dir.Close()
+ pinned, err := OpenDir(stage)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pinned.Close()
+ if err := os.Rename(stage, stage+"-moved"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(victim, stage); err != nil {
+ t.Fatal(err)
+ }
+ if err := reapStage(int(dir.Fd()), name, pinned, time.Now().Add(-time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(stage+"-moved", "data")); err != nil {
+ t.Fatalf("replaced entry was not refused: %v", err)
+ }
+ if err := os.Rename(parent, parent+"-moved"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(out, parent); err != nil {
+ t.Fatal(err)
+ }
+ if err := reapTree(dir, time.Now().Add(-time.Hour), 0); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(victim, "data")); err != nil {
+ t.Fatalf("followed swapped parent: %v", err)
+ }
+ if err := Reap(parent); err == nil {
+ t.Fatal("accepted symlink root")
+ }
+}
+
+func TestReapRefusesBindMount(t *testing.T) {
+ root := t.TempDir()
+ out := t.TempDir()
+ name := ".carlos-write-" + strings.Repeat("d", 32)
+ victim := reapTestStage(t, out, name, true)
+ mount := filepath.Join(root, "mounted")
+ if err := os.Mkdir(mount, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := unix.Mount(out, mount, "", unix.MS_BIND, ""); err != nil {
+ if err == unix.EPERM || err == unix.EACCES {
+ t.Skip("bind mounts unavailable")
+ }
+ t.Fatal(err)
+ }
+ defer func() {
+ if err := unix.Unmount(mount, 0); err != nil {
+ t.Errorf("unmount: %v", err)
+ }
+ }()
+ if err := Reap(root); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(victim, "data")); err != nil {
+ t.Fatalf("traversed bind mount: %v", err)
+ }
+}
+
+func TestReapContinuesPastUnreadableDirectory(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("root bypasses directory permissions")
+ }
+ root := t.TempDir()
+ unreadable := filepath.Join(root, "unreadable")
+ if err := os.Mkdir(unreadable, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(unreadable, 0); err != nil {
+ t.Fatal(err)
+ }
+ defer os.Chmod(unreadable, 0o700)
+ stale := reapTestStage(t, root, ".carlos-write-"+strings.Repeat("9", 32), true)
+ if err := Reap(root); err == nil {
+ t.Fatal("unreadable directory was not reported")
+ }
+ if _, err := os.Lstat(stale); !os.IsNotExist(err) {
+ t.Fatalf("unreadable sibling prevented cleanup: %v", err)
+ }
+}
diff --git a/internal/safefile/reap_other.go b/internal/safefile/reap_other.go
new file mode 100644
index 000000000..74f6e02f5
--- /dev/null
+++ b/internal/safefile/reap_other.go
@@ -0,0 +1,6 @@
+//go:build !linux
+
+package safefile
+
+// Reap has no work on platforms whose writer uses ordinary temporary files.
+func Reap(dir string) error { return nil }
diff --git a/internal/steering/route53.go b/internal/steering/route53.go
index 28fd8fcdf..6ec1fa7a0 100644
--- a/internal/steering/route53.go
+++ b/internal/steering/route53.go
@@ -27,6 +27,7 @@ import (
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/route53"
r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
+ "github.com/carlosframework/platform/internal/rootaws"
)
const (
@@ -113,7 +114,7 @@ func NewClient(ctx context.Context) (*route53.Client, error) {
if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" {
opts = append(opts, awsconfig.WithRegion("us-east-1"))
}
- cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
+ cfg, err := rootaws.LoadDefaultConfig(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("steering: load aws config: %w", err)
}
diff --git a/main.go b/main.go
index 90efe073b..17d8cfcfc 100644
--- a/main.go
+++ b/main.go
@@ -11,11 +11,13 @@ import (
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/host"
"github.com/carlosframework/platform/internal/instanceexec"
+ "github.com/carlosframework/platform/internal/rootaws"
"github.com/carlosframework/platform/internal/version"
"github.com/carlosframework/platform/internal/vet"
)
func main() {
+ rootaws.ScrubEnvironment()
// Failure is reported through log.Fatal everywhere in this binary, so
// the logger is where the "this went wrong" colour goes — once, rather
// than at a few hundred call sites. Off unless stderr is a terminal, so