detached 058b81b

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 @@
11 package handlers
22
33 import (
4+ "bufio"
5+ "bytes"
46 "context"
57 "fmt"
8+ "os"
69 "path/filepath"
710 "strings"
811
@@ -177,6 +180,12 @@ func (h *ConfigureSystemHandler) Handle(ctx context.Context, cmd commands.Config
177180 return result, err
178181 }
179182
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+
180189 h.logger.Info("System configuration completed successfully")
181190
182191 result.Success = true
@@ -185,3 +194,142 @@ func (h *ConfigureSystemHandler) Handle(ctx context.Context, cmd commands.Config
185194
186195 return result, nil
187196 }
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 handlers1 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, err180 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 = true191 result.Success = true
@@ -185,3 +194,142 @@ func (h *ConfigureSystemHandler) Handle(ctx context.Context, cmd commands.Config
185 194
186 return result, nil195 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close