feat(network): migrate iwd WiFi credentials to NetworkManager profiles post-install
- commit
- 058b81bb657a7ad65fe50493870b36eafc468a8d
- author
- brice · · 2026-02-27 18:32 UTC
- parent
- b39b9c4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +148 -0
M internal/application/handlers/configure_system_handler.go +148 -0
| @@ -1,8 +1,11 @@ | |||
| 1 | 1 | package handlers | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "bytes" | |
| 4 | 6 | "context" | |
| 5 | 7 | "fmt" | |
| 8 | + | "os" | |
| 6 | 9 | "path/filepath" | |
| 7 | 10 | "strings" | |
| 8 | 11 | ||
| @@ -177,6 +180,12 @@ func (h *ConfigureSystemHandler) Handle(ctx context.Context, cmd commands.Config | |||
| 177 | 180 | return result, err | |
| 178 | 181 | } | |
| 179 | 182 | ||
| 183 | + | // Migrate iwd WiFi credentials from live ISO to NetworkManager profiles in the installed system. | |
| 184 | + | if err := h.migrateIWDProfiles(cmd.MountPoint); err != nil { | |
| 185 | + | // Non-fatal: log and continue. User can configure WiFi manually after boot. | |
| 186 | + | h.logger.Warn("Failed to migrate iwd WiFi profiles", "error", err) | |
| 187 | + | } | |
| 188 | + | ||
| 180 | 189 | h.logger.Info("System configuration completed successfully") | |
| 181 | 190 | ||
| 182 | 191 | result.Success = true | |
| @@ -185,3 +194,142 @@ func (h *ConfigureSystemHandler) Handle(ctx context.Context, cmd commands.Config | |||
| 185 | 194 | ||
| 186 | 195 | return result, nil | |
| 187 | 196 | } | |
| 197 | + | ||
| 198 | + | // migrateIWDProfiles reads *.psk files from the live ISO's /var/lib/iwd and | |
| 199 | + | // writes equivalent NetworkManager keyfile profiles into the installed system. | |
| 200 | + | // This ensures the system has WiFi connectivity on first boot without manual setup. | |
| 201 | + | func (h *ConfigureSystemHandler) migrateIWDProfiles(mountPoint string) error { | |
| 202 | + | const iwdDir = "/var/lib/iwd" | |
| 203 | + | ||
| 204 | + | entries, err := os.ReadDir(iwdDir) | |
| 205 | + | if err != nil { | |
| 206 | + | if os.IsNotExist(err) { | |
| 207 | + | h.logger.Info("No iwd profiles found (probably ethernet install), skipping WiFi migration") | |
| 208 | + | return nil | |
| 209 | + | } | |
| 210 | + | return fmt.Errorf("failed to read iwd directory: %w", err) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | nmConnDir := filepath.Join(mountPoint, "etc", "NetworkManager", "system-connections") | |
| 214 | + | if err := h.fs.MkdirAll(nmConnDir, 0700); err != nil { | |
| 215 | + | return fmt.Errorf("failed to create NM connections directory: %w", err) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | migrated := 0 | |
| 219 | + | for _, entry := range entries { | |
| 220 | + | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".psk") { | |
| 221 | + | continue | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | data, err := os.ReadFile(filepath.Join(iwdDir, entry.Name())) | |
| 225 | + | if err != nil { | |
| 226 | + | h.logger.Warn("Failed to read iwd profile", "file", entry.Name(), "error", err) | |
| 227 | + | continue | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // SSID is the filename without the .psk extension. | |
| 231 | + | ssid := strings.TrimSuffix(entry.Name(), ".psk") | |
| 232 | + | // iwd hex-encodes SSIDs containing non-alphanumeric chars as =<hex>. | |
| 233 | + | if strings.HasPrefix(ssid, "=") { | |
| 234 | + | decoded, err := decodeIWDHexSSID(ssid[1:]) | |
| 235 | + | if err == nil { | |
| 236 | + | ssid = decoded | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | psk := extractIWDValue(data, "PreSharedKey") | |
| 241 | + | passphrase := extractIWDValue(data, "Passphrase") | |
| 242 | + | ||
| 243 | + | // Prefer the raw passphrase for NM; fall back to PreSharedKey (hex PSK). | |
| 244 | + | password := passphrase | |
| 245 | + | if password == "" { | |
| 246 | + | password = psk | |
| 247 | + | } | |
| 248 | + | if password == "" { | |
| 249 | + | h.logger.Warn("iwd profile has no usable password, skipping", "ssid", ssid) | |
| 250 | + | continue | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | profile := buildNMWiFiProfile(ssid, password) | |
| 254 | + | profilePath := filepath.Join(nmConnDir, ssid+".nmconnection") | |
| 255 | + | if err := h.fs.WriteFile(profilePath, []byte(profile), 0600); err != nil { | |
| 256 | + | h.logger.Warn("Failed to write NM WiFi profile", "ssid", ssid, "error", err) | |
| 257 | + | continue | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | h.logger.Info("Migrated WiFi profile", "ssid", ssid) | |
| 261 | + | migrated++ | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | if migrated > 0 { | |
| 265 | + | h.logger.Info("WiFi profiles migrated to NetworkManager", "count", migrated) | |
| 266 | + | } | |
| 267 | + | return nil | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // extractIWDValue parses a key=value line from an iwd .psk file. | |
| 271 | + | func extractIWDValue(data []byte, key string) string { | |
| 272 | + | scanner := bufio.NewScanner(bytes.NewReader(data)) | |
| 273 | + | prefix := key + "=" | |
| 274 | + | for scanner.Scan() { | |
| 275 | + | line := strings.TrimSpace(scanner.Text()) | |
| 276 | + | if strings.HasPrefix(line, prefix) { | |
| 277 | + | return strings.TrimPrefix(line, prefix) | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | return "" | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // decodeIWDHexSSID decodes an iwd hex-encoded SSID (used when SSID contains | |
| 284 | + | // non-alphanumeric characters). Format: lowercase hex pairs. | |
| 285 | + | func decodeIWDHexSSID(hex string) (string, error) { | |
| 286 | + | if len(hex)%2 != 0 { | |
| 287 | + | return "", fmt.Errorf("odd hex length") | |
| 288 | + | } | |
| 289 | + | b := make([]byte, len(hex)/2) | |
| 290 | + | for i := 0; i < len(hex); i += 2 { | |
| 291 | + | hi := hexNibble(hex[i]) | |
| 292 | + | lo := hexNibble(hex[i+1]) | |
| 293 | + | if hi < 0 || lo < 0 { | |
| 294 | + | return "", fmt.Errorf("invalid hex char") | |
| 295 | + | } | |
| 296 | + | b[i/2] = byte(hi<<4 | lo) | |
| 297 | + | } | |
| 298 | + | return string(b), nil | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | func hexNibble(c byte) int { | |
| 302 | + | switch { | |
| 303 | + | case c >= '0' && c <= '9': | |
| 304 | + | return int(c - '0') | |
| 305 | + | case c >= 'a' && c <= 'f': | |
| 306 | + | return int(c-'a') + 10 | |
| 307 | + | case c >= 'A' && c <= 'F': | |
| 308 | + | return int(c-'A') + 10 | |
| 309 | + | } | |
| 310 | + | return -1 | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // buildNMWiFiProfile generates a NetworkManager keyfile for a WPA-PSK network. | |
| 314 | + | func buildNMWiFiProfile(ssid, psk string) string { | |
| 315 | + | return fmt.Sprintf(`[connection] | |
| 316 | + | id=%s | |
| 317 | + | type=wifi | |
| 318 | + | autoconnect=true | |
| 319 | + | ||
| 320 | + | [wifi] | |
| 321 | + | ssid=%s | |
| 322 | + | mode=infrastructure | |
| 323 | + | ||
| 324 | + | [wifi-security] | |
| 325 | + | auth-alg=open | |
| 326 | + | key-mgmt=wpa-psk | |
| 327 | + | psk=%s | |
| 328 | + | ||
| 329 | + | [ipv4] | |
| 330 | + | method=auto | |
| 331 | + | ||
| 332 | + | [ipv6] | |
| 333 | + | method=auto | |
| 334 | + | `, ssid, ssid, psk) | |
| 335 | + | } | |
| @@ -1,8 +1,11 @@ | |||||
| 1 | package handlers | 1 | package handlers | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bufio" | |||
| 5 | + | "bytes" | |||
| 4 | "context" | 6 | "context" | ||
| 5 | "fmt" | 7 | "fmt" | ||
| 8 | + | "os" | |||
| 6 | "path/filepath" | 9 | "path/filepath" | ||
| 7 | "strings" | 10 | "strings" | ||
| 8 | 11 | ||||
| @@ -177,6 +180,12 @@ func (h *ConfigureSystemHandler) Handle(ctx context.Context, cmd commands.Config | |||||
| 177 | return result, err | 180 | return result, err | ||
| 178 | } | 181 | } | ||
| 179 | 182 | ||||
| 183 | + | // Migrate iwd WiFi credentials from live ISO to NetworkManager profiles in the installed system. | |||
| 184 | + | if err := h.migrateIWDProfiles(cmd.MountPoint); err != nil { | |||
| 185 | + | // Non-fatal: log and continue. User can configure WiFi manually after boot. | |||
| 186 | + | h.logger.Warn("Failed to migrate iwd WiFi profiles", "error", err) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 180 | h.logger.Info("System configuration completed successfully") | 189 | h.logger.Info("System configuration completed successfully") | ||
| 181 | 190 | ||||
| 182 | result.Success = true | 191 | result.Success = true | ||
| @@ -185,3 +194,142 @@ func (h *ConfigureSystemHandler) Handle(ctx context.Context, cmd commands.Config | |||||
| 185 | 194 | ||||
| 186 | return result, nil | 195 | return result, nil | ||
| 187 | } | 196 | } | ||
| 197 | + | ||||
| 198 | + | // migrateIWDProfiles reads *.psk files from the live ISO's /var/lib/iwd and | |||
| 199 | + | // writes equivalent NetworkManager keyfile profiles into the installed system. | |||
| 200 | + | // This ensures the system has WiFi connectivity on first boot without manual setup. | |||
| 201 | + | func (h *ConfigureSystemHandler) migrateIWDProfiles(mountPoint string) error { | |||
| 202 | + | const iwdDir = "/var/lib/iwd" | |||
| 203 | + | ||||
| 204 | + | entries, err := os.ReadDir(iwdDir) | |||
| 205 | + | if err != nil { | |||
| 206 | + | if os.IsNotExist(err) { | |||
| 207 | + | h.logger.Info("No iwd profiles found (probably ethernet install), skipping WiFi migration") | |||
| 208 | + | return nil | |||
| 209 | + | } | |||
| 210 | + | return fmt.Errorf("failed to read iwd directory: %w", err) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | nmConnDir := filepath.Join(mountPoint, "etc", "NetworkManager", "system-connections") | |||
| 214 | + | if err := h.fs.MkdirAll(nmConnDir, 0700); err != nil { | |||
| 215 | + | return fmt.Errorf("failed to create NM connections directory: %w", err) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | migrated := 0 | |||
| 219 | + | for _, entry := range entries { | |||
| 220 | + | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".psk") { | |||
| 221 | + | continue | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | data, err := os.ReadFile(filepath.Join(iwdDir, entry.Name())) | |||
| 225 | + | if err != nil { | |||
| 226 | + | h.logger.Warn("Failed to read iwd profile", "file", entry.Name(), "error", err) | |||
| 227 | + | continue | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // SSID is the filename without the .psk extension. | |||
| 231 | + | ssid := strings.TrimSuffix(entry.Name(), ".psk") | |||
| 232 | + | // iwd hex-encodes SSIDs containing non-alphanumeric chars as =<hex>. | |||
| 233 | + | if strings.HasPrefix(ssid, "=") { | |||
| 234 | + | decoded, err := decodeIWDHexSSID(ssid[1:]) | |||
| 235 | + | if err == nil { | |||
| 236 | + | ssid = decoded | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | psk := extractIWDValue(data, "PreSharedKey") | |||
| 241 | + | passphrase := extractIWDValue(data, "Passphrase") | |||
| 242 | + | ||||
| 243 | + | // Prefer the raw passphrase for NM; fall back to PreSharedKey (hex PSK). | |||
| 244 | + | password := passphrase | |||
| 245 | + | if password == "" { | |||
| 246 | + | password = psk | |||
| 247 | + | } | |||
| 248 | + | if password == "" { | |||
| 249 | + | h.logger.Warn("iwd profile has no usable password, skipping", "ssid", ssid) | |||
| 250 | + | continue | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | profile := buildNMWiFiProfile(ssid, password) | |||
| 254 | + | profilePath := filepath.Join(nmConnDir, ssid+".nmconnection") | |||
| 255 | + | if err := h.fs.WriteFile(profilePath, []byte(profile), 0600); err != nil { | |||
| 256 | + | h.logger.Warn("Failed to write NM WiFi profile", "ssid", ssid, "error", err) | |||
| 257 | + | continue | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | h.logger.Info("Migrated WiFi profile", "ssid", ssid) | |||
| 261 | + | migrated++ | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | if migrated > 0 { | |||
| 265 | + | h.logger.Info("WiFi profiles migrated to NetworkManager", "count", migrated) | |||
| 266 | + | } | |||
| 267 | + | return nil | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | // extractIWDValue parses a key=value line from an iwd .psk file. | |||
| 271 | + | func extractIWDValue(data []byte, key string) string { | |||
| 272 | + | scanner := bufio.NewScanner(bytes.NewReader(data)) | |||
| 273 | + | prefix := key + "=" | |||
| 274 | + | for scanner.Scan() { | |||
| 275 | + | line := strings.TrimSpace(scanner.Text()) | |||
| 276 | + | if strings.HasPrefix(line, prefix) { | |||
| 277 | + | return strings.TrimPrefix(line, prefix) | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | return "" | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | // decodeIWDHexSSID decodes an iwd hex-encoded SSID (used when SSID contains | |||
| 284 | + | // non-alphanumeric characters). Format: lowercase hex pairs. | |||
| 285 | + | func decodeIWDHexSSID(hex string) (string, error) { | |||
| 286 | + | if len(hex)%2 != 0 { | |||
| 287 | + | return "", fmt.Errorf("odd hex length") | |||
| 288 | + | } | |||
| 289 | + | b := make([]byte, len(hex)/2) | |||
| 290 | + | for i := 0; i < len(hex); i += 2 { | |||
| 291 | + | hi := hexNibble(hex[i]) | |||
| 292 | + | lo := hexNibble(hex[i+1]) | |||
| 293 | + | if hi < 0 || lo < 0 { | |||
| 294 | + | return "", fmt.Errorf("invalid hex char") | |||
| 295 | + | } | |||
| 296 | + | b[i/2] = byte(hi<<4 | lo) | |||
| 297 | + | } | |||
| 298 | + | return string(b), nil | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | func hexNibble(c byte) int { | |||
| 302 | + | switch { | |||
| 303 | + | case c >= '0' && c <= '9': | |||
| 304 | + | return int(c - '0') | |||
| 305 | + | case c >= 'a' && c <= 'f': | |||
| 306 | + | return int(c-'a') + 10 | |||
| 307 | + | case c >= 'A' && c <= 'F': | |||
| 308 | + | return int(c-'A') + 10 | |||
| 309 | + | } | |||
| 310 | + | return -1 | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | // buildNMWiFiProfile generates a NetworkManager keyfile for a WPA-PSK network. | |||
| 314 | + | func buildNMWiFiProfile(ssid, psk string) string { | |||
| 315 | + | return fmt.Sprintf(`[connection] | |||
| 316 | + | id=%s | |||
| 317 | + | type=wifi | |||
| 318 | + | autoconnect=true | |||
| 319 | + | ||||
| 320 | + | [wifi] | |||
| 321 | + | ssid=%s | |||
| 322 | + | mode=infrastructure | |||
| 323 | + | ||||
| 324 | + | [wifi-security] | |||
| 325 | + | auth-alg=open | |||
| 326 | + | key-mgmt=wpa-psk | |||
| 327 | + | psk=%s | |||
| 328 | + | ||||
| 329 | + | [ipv4] | |||
| 330 | + | method=auto | |||
| 331 | + | ||||
| 332 | + | [ipv6] | |||
| 333 | + | method=auto | |||
| 334 | + | `, ssid, ssid, psk) | |||
| 335 | + | } | |||