@@ -2,6 +2,8 @@ package v1
22
33import (
44 "context"
5+ _ "embed"
6+ "encoding/base64"
57 "fmt"
68 "strings"
79 "time"
@@ -20,6 +22,12 @@ const (
2022 nebiusCPUImageFamily = "ubuntu24.04-driverless"
2123)
2224
25+ //go:embed scripts/brev-apply-docker-firewall.sh
26+ var dockerFirewallScript string
27+
28+ //go:embed scripts/10-brev-firewall.conf
29+ var dockerFirewallDropIn string
30+
2331//nolint:gocyclo,funlen // Complex instance creation with resource management
2432func (c * NebiusClient ) CreateInstance (ctx context.Context , attrs v1.CreateInstanceAttrs ) (* v1.Instance , error ) {
2533 // Track created resources for automatic cleanup on failure
@@ -868,7 +876,6 @@ func matchesTagFilters(instanceTags map[string]string, tagFilters map[string][]s
868876 return true
869877}
870878
871- //nolint:dupl // StopInstance and StartInstance have similar structure but different operations
872879func (c * NebiusClient ) StopInstance (ctx context.Context , instanceID v1.CloudProviderInstanceID ) error {
873880 c .logger .Debug (ctx , "initiating instance stop operation" ,
874881 v1 .LogField ("instanceID" , instanceID ))
@@ -906,7 +913,6 @@ func (c *NebiusClient) StopInstance(ctx context.Context, instanceID v1.CloudProv
906913 return nil
907914}
908915
909- //nolint:dupl // StartInstance and StopInstance have similar structure but different operations
910916func (c * NebiusClient ) StartInstance (ctx context.Context , instanceID v1.CloudProviderInstanceID ) error {
911917 c .logger .Debug (ctx , "initiating instance start operation" ,
912918 v1 .LogField ("instanceID" , instanceID ))
@@ -916,17 +922,18 @@ func (c *NebiusClient) StartInstance(ctx context.Context, instanceID v1.CloudPro
916922 Id : string (instanceID ),
917923 })
918924 if err != nil {
919- return fmt .Errorf ("failed to initiate instance start: %w" , err )
925+ return fmt .Errorf ("failed to initiate instance start: %w" , handleErrToCloudErr ( err ) )
920926 }
921927
922928 // Wait for the start operation to complete
923929 finalOp , err := operation .Wait (ctx )
924930 if err != nil {
925- return fmt .Errorf ("failed to wait for instance start: %w" , err )
931+ return fmt .Errorf ("failed to wait for instance start: %w" , handleErrToCloudErr ( err ) )
926932 }
927933
928934 if ! finalOp .Successful () {
929- return fmt .Errorf ("instance start failed: %v" , finalOp .Status ())
935+ statusErr := fmt .Errorf ("instance start failed: %v" , finalOp .Status ())
936+ return handleErrToCloudErr (statusErr )
930937 }
931938
932939 c .logger .Debug (ctx , "start operation completed, waiting for instance to reach RUNNING state" ,
@@ -1577,13 +1584,12 @@ func (c *NebiusClient) cleanupOrphanedBootDisks(ctx context.Context, testID stri
15771584}
15781585
15791586// generateCloudInitUserData generates a cloud-init user-data script for SSH key injection and firewall configuration
1580- // This is inspired by Shadeform's LaunchConfiguration approach but uses cloud-init instead of base64 scripts
1587+ // This is inspired by Shadeform's LaunchConfiguration approach but uses cloud-init directly.
15811588func generateCloudInitUserData (publicKey string , firewallRules v1.FirewallRules ) string {
15821589 // Start with cloud-init header
15831590 script := `#cloud-config
15841591packages:
15851592 - ufw
1586- - iptables-persistent
15871593`
15881594
15891595 // Add SSH key configuration if provided
@@ -1593,35 +1599,19 @@ packages:
15931599` , publicKey )
15941600 }
15951601
1602+ script += generateDockerFirewallWriteFiles ()
1603+
15961604 var commands []string
15971605
1598- // Fix a systemd race condition: ufw.service and netfilter-persistent.service
1599- // both start in parallel (both are Before=network-pre.target with no mutual
1600- // ordering). Both call iptables-restore concurrently, and with the iptables-nft
1601- // backend the competing nftables transactions cause UFW to fail with
1602- // "iptables-restore: line 4 failed". This drop-in forces UFW to wait for
1603- // netfilter-persistent to finish first.
1604- commands = append (commands ,
1605- "sudo mkdir -p /etc/systemd/system/ufw.service.d" ,
1606- `printf '[Unit]\nAfter=netfilter-persistent.service\n' | sudo tee /etc/systemd/system/ufw.service.d/after-netfilter.conf > /dev/null` ,
1607- "sudo systemctl daemon-reload" ,
1608- )
1606+ commands = append (commands , "sudo systemctl daemon-reload" )
16091607
16101608 // Generate UFW firewall commands (similar to Shadeform's approach)
16111609 // UFW (Uncomplicated Firewall) is available on Ubuntu/Debian instances
16121610 commands = append (commands , generateUFWCommands (firewallRules )... )
16131611
1614- // Generate IPTables firewall commands to ensure docker ports are not made immediately
1615- // accessible from the internet by default.
1616- commands = append (commands , generateIPTablesCommands ()... )
1617-
1618- // Save the complete iptables state (UFW chains + DOCKER-USER rules) so it
1619- // survives instance stop/start cycles. Cloud-init runcmd only executes on
1620- // first boot; on subsequent boots netfilter-persistent restores this snapshot,
1621- // then UFW starts after it (due to the drop-in above) and re-applies its rules.
1622- // This provides defense-in-depth: even if UFW fails for any reason, the
1623- // netfilter-persistent snapshot ensures port 22 and DOCKER-USER rules persist.
1624- commands = append (commands , "sudo netfilter-persistent save" )
1612+ // Apply immediately for images where Docker is already running. The
1613+ // docker.service ExecStartPost hook handles images where Docker starts later.
1614+ commands = append (commands , "sudo /usr/local/sbin/brev-apply-docker-firewall.sh || true" )
16251615
16261616 if len (commands ) > 0 {
16271617 // Use runcmd to execute firewall setup commands
@@ -1663,25 +1653,53 @@ func generateUFWCommands(firewallRules v1.FirewallRules) []string {
16631653 return commands
16641654}
16651655
1666- // generateIPTablesCommands generates IPTables firewall commands to ensure docker ports are not made immediately
1667- // accessible from the internet by default.
1668- func generateIPTablesCommands () []string {
1669- commands := []string {
1670- "iptables -F DOCKER-USER" ,
1671- "iptables -A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT" ,
1672- "iptables -A DOCKER-USER -i docker0 ! -o docker0 -j ACCEPT" ,
1673- "iptables -A DOCKER-USER -i br+ ! -o br+ -j ACCEPT" ,
1674- "iptables -A DOCKER-USER -i cni+ ! -o cni+ -j ACCEPT" , // TODO: add these back in when we have a way to test it
1675- "iptables -A DOCKER-USER -i cali+ ! -o cali+ -j ACCEPT" ,
1676- "iptables -A DOCKER-USER -i docker0 -o docker0 -j ACCEPT" ,
1677- "iptables -A DOCKER-USER -i br+ -o br+ -j ACCEPT" ,
1678- "iptables -A DOCKER-USER -i cni+ -o cni+ -j ACCEPT" ,
1679- "iptables -A DOCKER-USER -i cali+ -o cali+ -j ACCEPT" ,
1680- "iptables -A DOCKER-USER -i lo -j ACCEPT" ,
1681- "iptables -A DOCKER-USER -j DROP" ,
1682- "iptables -A DOCKER-USER -j RETURN" , // Expected by Docker
1683- }
1684- return commands
1656+ const (
1657+ // Keep these generated paths stable: cloud-init, systemd, and validation
1658+ // tests all depend on this Docker firewall handoff.
1659+ dockerFirewallScriptPath = "/usr/local/sbin/brev-apply-docker-firewall.sh"
1660+
1661+ // This is a docker.service drop-in because the firewall rules must be
1662+ // re-applied immediately after Docker initializes or resets DOCKER-USER. If
1663+ // we need a separately inspectable status surface later, this can move to a
1664+ // named oneshot unit such as brev-docker-firewall.service; for now the
1665+ // execution is visible through docker.service journal/status output.
1666+ dockerServiceDropInDir = "/etc/systemd/system/docker.service.d"
1667+ dockerFirewallDropInPath = dockerServiceDropInDir + "/10-brev-firewall.conf"
1668+ )
1669+
1670+ func generateDockerFirewallWriteFiles () string {
1671+ // This function emits the only write_files block in this cloud-config. If
1672+ // another generated file is added later, merge it into this block instead of
1673+ // adding a second top-level write_files key.
1674+ //
1675+ // Docker published ports are not governed by UFW's INPUT policy. Docker adds
1676+ // NAT/FORWARD rules that can make `docker run -p host:container` reachable
1677+ // from the public internet even when UFW says incoming traffic is denied.
1678+ //
1679+ // DOCKER-USER is Docker's documented filter hook for this traffic. The script
1680+ // ensures the chain exists before configuring it. If Docker already created
1681+ // the chain, the create command fails harmlessly and the script continues.
1682+ //
1683+ // The generated script exits successfully even if an iptables command fails
1684+ // because failing Docker startup would be worse operationally. Validation
1685+ // tests assert that the rule set is actually present and blocks published
1686+ // ports.
1687+ //
1688+ // UFW persists its own rules in /etc/ufw; Docker firewall rules are applied
1689+ // through cloud-init and the docker.service post-start hook.
1690+ return fmt .Sprintf (`
1691+ write_files:
1692+ - path: %s
1693+ owner: root:root
1694+ permissions: '0755'
1695+ encoding: b64
1696+ content: %s
1697+ - path: %s
1698+ owner: root:root
1699+ permissions: '0644'
1700+ encoding: b64
1701+ content: %s
1702+ ` , dockerFirewallScriptPath , base64 .StdEncoding .EncodeToString ([]byte (dockerFirewallScript )), dockerFirewallDropInPath , base64 .StdEncoding .EncodeToString ([]byte (dockerFirewallDropIn )))
16851703}
16861704
16871705// convertIngressRuleToUFW converts an ingress firewall rule to UFW command(s)
0 commit comments