66 "fmt"
77 "io"
88 "math/rand/v2"
9+ "net"
10+ "net/http"
911 "net/url"
1012 "os"
1113 "strconv"
@@ -1181,10 +1183,13 @@ func applyLaunchableConfig(cwOptions *store.CreateWorkspacesOptions, launchableI
11811183 cwOptions .WorkspaceGroupID = wsReq .WorkspaceGroupID
11821184 }
11831185
1184- // Location
1186+ // Location / sub-location
11851187 if wsReq .Location != "" {
11861188 cwOptions .Location = wsReq .Location
11871189 }
1190+ if wsReq .SubLocation != "" {
1191+ cwOptions .SubLocation = wsReq .SubLocation
1192+ }
11881193
11891194 // Disk storage — the API may return a bare number (e.g., "256") or with
11901195 // a unit suffix (e.g., "256Gi"). The server's ParseDiskStorage expects a
@@ -1215,6 +1220,10 @@ func applyLaunchableConfig(cwOptions *store.CreateWorkspacesOptions, launchableI
12151220 cwOptions .PortMappings = portMappings
12161221 }
12171222
1223+ if len (wsReq .FirewallRules ) > 0 {
1224+ cwOptions .FirewallRules = resolveFirewallRulesClientIP (wsReq .FirewallRules , publicIPLookup )
1225+ }
1226+
12181227 // Files from launchable
12191228 if info .File != nil {
12201229 cwOptions .Files = []map [string ]string {
@@ -1238,6 +1247,98 @@ func applyLaunchableConfig(cwOptions *store.CreateWorkspacesOptions, launchableI
12381247 cwOptions .Labels = labels
12391248}
12401249
1250+ // resolveFirewallRulesClientIP fills ClientIPs on any "user-ip" rule that
1251+ // doesn't already have one, calling lookupIP at most once. Rules are left
1252+ // unchanged on lookup failure or unparseable IPs.
1253+ func resolveFirewallRulesClientIP (rules []store.CreateFirewallRule , lookupIP func () (string , error )) []store.CreateFirewallRule {
1254+ out := make ([]store.CreateFirewallRule , len (rules ))
1255+ copy (out , rules )
1256+
1257+ var (
1258+ ip string
1259+ ipErr error
1260+ looked bool
1261+ )
1262+ for i := range out {
1263+ if out [i ].AllowedIPs != "user-ip" || len (out [i ].ClientIPs ) > 0 {
1264+ continue
1265+ }
1266+ if ! looked {
1267+ ip , ipErr = lookupIP ()
1268+ looked = true
1269+ }
1270+ if ipErr != nil || ip == "" {
1271+ continue
1272+ }
1273+ cidr := toHostCIDR (ip )
1274+ if cidr == "" {
1275+ continue
1276+ }
1277+ out [i ].ClientIPs = []string {cidr }
1278+ }
1279+ return out
1280+ }
1281+
1282+ // toHostCIDR returns the single-host CIDR for an IP literal: /32 for IPv4,
1283+ // /128 for IPv6. Returns "" if raw isn't a valid IP.
1284+ func toHostCIDR (raw string ) string {
1285+ parsed := net .ParseIP (strings .TrimSpace (raw ))
1286+ if parsed == nil {
1287+ return ""
1288+ }
1289+ if v4 := parsed .To4 (); v4 != nil {
1290+ return v4 .String () + "/32"
1291+ }
1292+ return parsed .String () + "/128"
1293+ }
1294+
1295+ // publicIPLookup is a var so tests can stub it.
1296+ var publicIPLookup = resolvePublicIP
1297+
1298+ // publicIPEndpoints are tried in order until one returns a valid IP.
1299+ // All return the IP as a plain-text body.
1300+ var publicIPEndpoints = []string {
1301+ "https://api.ipify.org" ,
1302+ "https://ifconfig.me/ip" ,
1303+ "https://checkip.amazonaws.com" ,
1304+ }
1305+
1306+ func resolvePublicIP () (string , error ) {
1307+ client := & http.Client {Timeout : 3 * time .Second }
1308+ var lastErr error
1309+ for _ , url := range publicIPEndpoints {
1310+ ip , err := fetchPublicIP (client , url )
1311+ if err == nil {
1312+ return ip , nil
1313+ }
1314+ lastErr = err
1315+ }
1316+ if lastErr == nil {
1317+ lastErr = fmt .Errorf ("no public IP endpoints configured" )
1318+ }
1319+ return "" , lastErr
1320+ }
1321+
1322+ func fetchPublicIP (client * http.Client , url string ) (string , error ) {
1323+ resp , err := client .Get (url )
1324+ if err != nil {
1325+ return "" , breverrors .WrapAndTrace (err )
1326+ }
1327+ defer resp .Body .Close () //nolint:errcheck // best-effort
1328+ if resp .StatusCode != http .StatusOK {
1329+ return "" , fmt .Errorf ("%s returned status %d" , url , resp .StatusCode )
1330+ }
1331+ body , err := io .ReadAll (io .LimitReader (resp .Body , 64 ))
1332+ if err != nil {
1333+ return "" , breverrors .WrapAndTrace (err )
1334+ }
1335+ ipStr := strings .TrimSpace (string (body ))
1336+ if net .ParseIP (ipStr ) == nil {
1337+ return "" , fmt .Errorf ("%s returned non-IP response: %q" , url , ipStr )
1338+ }
1339+ return ipStr , nil
1340+ }
1341+
12411342// normalizeDiskStorage ensures a disk storage value has a Kubernetes quantity suffix.
12421343// If the value is purely numeric (e.g., "256"), appends "Gi". Otherwise passes through
12431344// as-is, trusting the server's ParseDiskStorage to handle formats like "256Gi", "100G", etc.
0 commit comments