fix(aws): forward ssh source cidrs
This commit is contained in:
parent
d50f2a42a5
commit
6126e6e090
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
},
|
||||
},
|
||||
|
||||
47
internal/cli/aws_ssh_cidr.go
Normal file
47
internal/cli/aws_ssh_cidr.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user