fix(aws): forward ssh source cidrs

This commit is contained in:
Vincent Koc 2026-05-01 04:08:22 -07:00
parent d50f2a42a5
commit 6126e6e090
No known key found for this signature in database
9 changed files with 88 additions and 12 deletions

View File

@ -194,7 +194,7 @@ CRABBOX_AWS_SSH_CIDRS optional comma-separated SSH source CIDRs
The AWS provider imports the local SSH public key as an EC2 key pair when needed, creates or reuses a `crabbox-runners` security group when no security group is supplied, launches one-time Spot instances, tags instances and volumes with Crabbox lease metadata, and terminates non-kept instances after the command.
SSH ingress for broker-created AWS security groups is source-scoped. If `CRABBOX_AWS_SSH_CIDRS` is set, Crabbox adds those CIDRs. Otherwise, when Cloudflare provides `CF-Connecting-IP`, the Worker adds that request source as `/32` or `/128`. Crabbox also revokes the old managed `0.0.0.0/0` SSH ingress rule when it touches the managed security group. Supplying `CRABBOX_AWS_SECURITY_GROUP_ID` makes network policy your responsibility.
SSH ingress for AWS security groups is source-scoped. If `CRABBOX_AWS_SSH_CIDRS` is set, Crabbox adds those CIDRs. Otherwise, the CLI sends its detected outbound IPv4 `/32` to the broker; when that is unavailable, the Worker falls back to `CF-Connecting-IP` as `/32` or `/128`. Direct AWS mode uses the same CIDR setting when it creates the local security group. Crabbox also revokes the old managed `0.0.0.0/0` SSH ingress rule when the broker touches the managed security group. Supplying `CRABBOX_AWS_SECURITY_GROUP_ID` makes network policy your responsibility.
## Machine Classes

View File

@ -72,7 +72,7 @@ Project allowlist example:
MVP SSH posture:
- SSH allowed only for worker machines.
- AWS broker-created security groups use `CRABBOX_AWS_SSH_CIDRS` when configured, otherwise the Cloudflare request source IP for the lease request.
- AWS security groups use `CRABBOX_AWS_SSH_CIDRS` when configured. Brokered leases otherwise use the CLI-detected outbound IPv4 CIDR or, as a fallback, the Cloudflare request source IP for the lease request.
- Hetzner direct mode still relies on provider networking/firewall defaults unless a profile supplies tighter controls.
- Key-only authentication.
- Dedicated `crabbox` user.

View File

@ -175,6 +175,7 @@ Environment:
CRABBOX_IDLE_TIMEOUT Default idle expiry, e.g. 30m
CRABBOX_TTL Maximum lease lifetime, e.g. 90m
CRABBOX_AWS_REGION Default eu-west-1
CRABBOX_AWS_SSH_CIDRS Comma-separated AWS SSH source CIDRs
CRABBOX_CAPACITY_MARKET spot or on-demand
CRABBOX_CAPACITY_REGIONS Comma-separated AWS Spot placement candidates
HCLOUD_TOKEN/HETZNER_TOKEN Direct Hetzner mode

View File

@ -365,7 +365,7 @@ func (c *AWSClient) ensureSecurityGroup(ctx context.Context, cfg Config) (string
groupID = aws.ToString(created.GroupId)
}
for _, port := range uniquePorts([]string{"22", cfg.SSHPort}) {
if err := c.allowTCP(ctx, groupID, port); err != nil && !strings.Contains(err.Error(), "InvalidPermission.Duplicate") {
if err := c.allowTCP(ctx, groupID, port, cfg.AWSSSHCIDRs); err != nil && !strings.Contains(err.Error(), "InvalidPermission.Duplicate") {
return "", err
}
}
@ -401,18 +401,27 @@ func (c *AWSClient) securityGroupVPC(ctx context.Context, cfg Config) (string, e
return aws.ToString(out.Subnets[0].VpcId), nil
}
func (c *AWSClient) allowTCP(ctx context.Context, groupID, port string) error {
func (c *AWSClient) allowTCP(ctx context.Context, groupID, port string, cidrs []string) error {
p, ok := parsePort32(port)
if !ok {
return exit(2, "invalid SSH port: %s", port)
}
ranges := make([]types.IpRange, 0, len(cidrs))
for _, cidr := range cidrs {
if strings.TrimSpace(cidr) != "" {
ranges = append(ranges, types.IpRange{CidrIp: aws.String(cidr), Description: aws.String("Crabbox SSH")})
}
}
if len(ranges) == 0 {
ranges = append(ranges, types.IpRange{CidrIp: aws.String("0.0.0.0/0"), Description: aws.String("Crabbox SSH")})
}
_, err := c.ec2.AuthorizeSecurityGroupIngress(ctx, &ec2.AuthorizeSecurityGroupIngressInput{
GroupId: aws.String(groupID),
IpPermissions: []types.IpPermission{
{
FromPort: aws.Int32(p),
IpProtocol: aws.String("tcp"),
IpRanges: []types.IpRange{{CidrIp: aws.String("0.0.0.0/0"), Description: aws.String("Crabbox SSH")}},
IpRanges: ranges,
ToPort: aws.Int32(p),
},
},

View File

@ -0,0 +1,47 @@
package cli
import (
"context"
"io"
"net/http"
"net/netip"
"strings"
"time"
)
func ensureAWSSSHCIDRs(ctx context.Context, cfg *Config) {
if cfg.Provider != "aws" || len(cfg.AWSSSHCIDRs) > 0 {
return
}
cidr, err := detectOutboundIPv4CIDR(ctx)
if err != nil || cidr == "" {
return
}
cfg.AWSSSHCIDRs = []string{cidr}
}
func detectOutboundIPv4CIDR(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://checkip.amazonaws.com", nil)
if err != nil {
return "", err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
body, err := io.ReadAll(io.LimitReader(res.Body, 128))
if err != nil {
return "", err
}
addr, err := netip.ParseAddr(strings.TrimSpace(string(body)))
if err != nil {
return "", err
}
if !addr.Is4() {
return "", nil
}
return netip.PrefixFrom(addr, 32).String(), nil
}

View File

@ -26,6 +26,7 @@ type Config struct {
AWSSubnetID string
AWSProfile string
AWSRootGB int32
AWSSSHCIDRs []string
SSHUser string
SSHKey string
SSHPort string
@ -212,12 +213,13 @@ type fileHetznerConfig struct {
}
type fileAWSConfig struct {
Region string `yaml:"region,omitempty"`
AMI string `yaml:"ami,omitempty"`
SecurityGroupID string `yaml:"securityGroupId,omitempty"`
SubnetID string `yaml:"subnetId,omitempty"`
InstanceProfile string `yaml:"instanceProfile,omitempty"`
RootGB int32 `yaml:"rootGB,omitempty"`
Region string `yaml:"region,omitempty"`
AMI string `yaml:"ami,omitempty"`
SecurityGroupID string `yaml:"securityGroupId,omitempty"`
SubnetID string `yaml:"subnetId,omitempty"`
InstanceProfile string `yaml:"instanceProfile,omitempty"`
RootGB int32 `yaml:"rootGB,omitempty"`
SSHCIDRs []string `yaml:"sshCIDRs,omitempty"`
}
type fileSSHConfig struct {
@ -427,6 +429,9 @@ func applyFileConfig(cfg *Config, file fileConfig) {
if file.AWS.RootGB > 0 {
cfg.AWSRootGB = file.AWS.RootGB
}
if len(file.AWS.SSHCIDRs) > 0 {
cfg.AWSSSHCIDRs = file.AWS.SSHCIDRs
}
}
if file.SSH != nil {
if file.SSH.User != "" {
@ -597,6 +602,9 @@ func applyEnv(cfg *Config) {
cfg.AWSSubnetID = getenv("CRABBOX_AWS_SUBNET_ID", cfg.AWSSubnetID)
cfg.AWSProfile = getenv("CRABBOX_AWS_INSTANCE_PROFILE", cfg.AWSProfile)
cfg.AWSRootGB = int32(getenvInt("CRABBOX_AWS_ROOT_GB", int(cfg.AWSRootGB)))
if cidrs := os.Getenv("CRABBOX_AWS_SSH_CIDRS"); cidrs != "" {
cfg.AWSSSHCIDRs = splitCommaList(cidrs)
}
cfg.SSHUser = getenv("CRABBOX_SSH_USER", cfg.SSHUser)
cfg.SSHKey = getenv("CRABBOX_SSH_KEY", cfg.SSHKey)
cfg.SSHPort = getenv("CRABBOX_SSH_PORT", cfg.SSHPort)

View File

@ -115,6 +115,7 @@ func (a App) configShow(args []string) error {
"subnetId": cfg.AWSSubnetID,
"instanceProfile": cfg.AWSProfile,
"rootGB": cfg.AWSRootGB,
"sshCIDRs": cfg.AWSSSHCIDRs,
},
}
if *jsonOut {
@ -131,7 +132,7 @@ func (a App) configShow(args []string) error {
fmt.Fprintf(a.Stdout, "blacksmith org=%s workflow=%s job=%s ref=%s idle_timeout=%s debug=%t\n", blank(cfg.Blacksmith.Org, "-"), blank(cfg.Blacksmith.Workflow, "-"), blank(cfg.Blacksmith.Job, "-"), blank(cfg.Blacksmith.Ref, "-"), cfg.Blacksmith.IdleTimeout, cfg.Blacksmith.Debug)
fmt.Fprintf(a.Stdout, "results junit=%s\n", blank(strings.Join(cfg.Results.JUnit, ","), "-"))
fmt.Fprintf(a.Stdout, "cache pnpm=%t npm=%t docker=%t git=%t max_gb=%d purge_on_release=%t\n", cfg.Cache.Pnpm, cfg.Cache.Npm, cfg.Cache.Docker, cfg.Cache.Git, cfg.Cache.MaxGB, cfg.Cache.PurgeOnRelease)
fmt.Fprintf(a.Stdout, "aws region=%s root_gb=%d\n", cfg.AWSRegion, cfg.AWSRootGB)
fmt.Fprintf(a.Stdout, "aws region=%s root_gb=%d ssh_cidrs=%s\n", cfg.AWSRegion, cfg.AWSRootGB, blank(strings.Join(cfg.AWSSSHCIDRs, ","), "-"))
return nil
}

View File

@ -29,6 +29,8 @@ lease:
aws:
region: eu-west-1
rootGB: 800
sshCIDRs:
- 198.51.100.7/32
sync:
checksum: true
gitSeed: false
@ -105,6 +107,9 @@ ssh:
if cfg.AWSRootGB != 800 {
t.Fatalf("AWSRootGB=%d want 800", cfg.AWSRootGB)
}
if len(cfg.AWSSSHCIDRs) != 1 || cfg.AWSSSHCIDRs[0] != "198.51.100.7/32" {
t.Fatalf("AWSSSHCIDRs=%v", cfg.AWSSSHCIDRs)
}
if cfg.SSHKey != filepath.Join(home, ".ssh", "crabbox") {
t.Fatalf("SSHKey=%q", cfg.SSHKey)
}
@ -149,6 +154,7 @@ func TestEnvOverridesConfig(t *testing.T) {
t.Setenv("CRABBOX_DEFAULT_CLASS", "fast")
t.Setenv("CRABBOX_TTL", "3h")
t.Setenv("CRABBOX_IDLE_TIMEOUT", "20m")
t.Setenv("CRABBOX_AWS_SSH_CIDRS", "198.51.100.7/32,203.0.113.8/32")
path := userConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
@ -164,6 +170,9 @@ func TestEnvOverridesConfig(t *testing.T) {
if cfg.Provider != "hetzner" || cfg.Class != "fast" || cfg.ServerType != "ccx43" || cfg.TTL.String() != "3h0m0s" || cfg.IdleTimeout.String() != "20m0s" {
t.Fatalf("unexpected config: provider=%s class=%s type=%s ttl=%s idle=%s", cfg.Provider, cfg.Class, cfg.ServerType, cfg.TTL, cfg.IdleTimeout)
}
if len(cfg.AWSSSHCIDRs) != 2 || cfg.AWSSSHCIDRs[0] != "198.51.100.7/32" || cfg.AWSSSHCIDRs[1] != "203.0.113.8/32" {
t.Fatalf("AWSSSHCIDRs=%v", cfg.AWSSSHCIDRs)
}
}
func TestRepoConfigIsYamlOnly(t *testing.T) {

View File

@ -242,6 +242,7 @@ func (c *CoordinatorClient) CreateLease(ctx context.Context, cfg Config, publicK
"awsSubnetID": cfg.AWSSubnetID,
"awsProfile": cfg.AWSProfile,
"awsRootGB": cfg.AWSRootGB,
"awsSSHCIDRs": cfg.AWSSSHCIDRs,
"capacity": map[string]any{
"market": cfg.Capacity.Market,
"strategy": cfg.Capacity.Strategy,