1package handlers
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11
12 "github.com/bnema/archup/internal/application/commands"
13 "github.com/bnema/archup/internal/application/dto"
14 "github.com/bnema/archup/internal/config"
15 "github.com/bnema/archup/internal/domain/ports"
16 "github.com/bnema/archup/internal/domain/system"
17 "github.com/bnema/archup/internal/domain/user"
18)
19
20// ConfigureSystemHandler handles system configuration
21type ConfigureSystemHandler struct {
22 fs ports.FileSystem
23 chrExec ports.ChrootExecutor
24 logger ports.Logger
25}
26
27// NewConfigureSystemHandler creates a new system configuration handler
28func NewConfigureSystemHandler(fs ports.FileSystem, chrExec ports.ChrootExecutor, logger ports.Logger) *ConfigureSystemHandler {
29 return &ConfigureSystemHandler{
30 fs: fs,
31 chrExec: chrExec,
32 logger: logger,
33 }
34}
35
36// Handle configures the system
37func (h *ConfigureSystemHandler) Handle(ctx context.Context, cmd commands.ConfigureSystemCommand) (*dto.ConfigureSystemResult, error) {
38 h.logger.Info("Starting system configuration", "hostname", cmd.Hostname)
39
40 result := &dto.ConfigureSystemResult{
41 Success: false,
42 Hostname: cmd.Hostname,
43 Timezone: cmd.Timezone,
44 ErrorDetail: "",
45 }
46
47 // Create system config domain object
48 sysConfig, err := system.NewSystemConfig(cmd.Hostname, cmd.Timezone, cmd.Locale, cmd.Keymap)
49 if err != nil {
50 h.logger.Error("Invalid system configuration", "error", err)
51 result.ErrorDetail = fmt.Sprintf("Invalid system configuration: %v", err)
52 return result, err
53 }
54
55 h.logger.Info("System configuration validated", "hostname", sysConfig.Hostname())
56
57 // Create user domain object
58 usr, err := user.NewUser(cmd.Username, cmd.UserShell)
59 if err != nil {
60 h.logger.Error("Invalid user configuration", "error", err)
61 result.ErrorDetail = fmt.Sprintf("Invalid user: %v", err)
62 return result, err
63 }
64
65 // Add user to wheel group for sudo
66 if err := usr.AddGroup("wheel"); err != nil {
67 h.logger.Warn("Failed to add user to wheel group", "error", err)
68 }
69
70 // Create credentials (validates password strength and constraints)
71 _, err = user.NewCredentials(cmd.UserPassword, cmd.RootPassword)
72 if err != nil {
73 h.logger.Error("Invalid credentials", "error", err)
74 result.ErrorDetail = fmt.Sprintf("Invalid credentials: %v", err)
75 return result, err
76 }
77
78 h.logger.Info("User configuration validated", "username", usr.Username())
79 h.logger.Info("Credentials validated successfully")
80
81 if err := h.fs.WriteFile(filepath.Join(cmd.MountPoint, "etc", "hostname"), []byte(sysConfig.Hostname()+"\n"), 0644); err != nil {
82 h.logger.Error("Failed to write hostname", "error", err)
83 result.ErrorDetail = fmt.Sprintf("Failed to write hostname: %v", err)
84 return result, err
85 }
86
87 hostsContent := fmt.Sprintf("127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\t%s.localdomain\t%s\n", sysConfig.Hostname(), sysConfig.Hostname())
88 if err := h.fs.WriteFile(filepath.Join(cmd.MountPoint, "etc", "hosts"), []byte(hostsContent), 0644); err != nil {
89 h.logger.Error("Failed to write hosts file", "error", err)
90 result.ErrorDetail = fmt.Sprintf("Failed to write hosts file: %v", err)
91 return result, err
92 }
93
94 if sysConfig.Timezone() != "" {
95 zonePath := filepath.Join("/usr/share/zoneinfo", sysConfig.Timezone())
96 if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "ln", "-sf", zonePath, "/etc/localtime"); err != nil {
97 h.logger.Error("Failed to set timezone", "error", err)
98 result.ErrorDetail = fmt.Sprintf("Failed to set timezone: %v", err)
99 return result, err
100 }
101 }
102
103 if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "hwclock", "--systohc"); err != nil {
104 h.logger.Error("Failed to set hardware clock", "error", err)
105 result.ErrorDetail = fmt.Sprintf("Failed to set hardware clock: %v", err)
106 return result, err
107 }
108
109 if sysConfig.Locale() != "" {
110 localeGen := fmt.Sprintf("%s UTF-8\n", sysConfig.Locale())
111 if err := h.fs.WriteFile(filepath.Join(cmd.MountPoint, "etc", "locale.gen"), []byte(localeGen), 0644); err != nil {
112 h.logger.Error("Failed to write locale.gen", "error", err)
113 result.ErrorDetail = fmt.Sprintf("Failed to write locale.gen: %v", err)
114 return result, err
115 }
116
117 localeConf := fmt.Sprintf("LANG=%s\n", sysConfig.Locale())
118 if err := h.fs.WriteFile(filepath.Join(cmd.MountPoint, "etc", "locale.conf"), []byte(localeConf), 0644); err != nil {
119 h.logger.Error("Failed to write locale.conf", "error", err)
120 result.ErrorDetail = fmt.Sprintf("Failed to write locale.conf: %v", err)
121 return result, err
122 }
123
124 if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "locale-gen"); err != nil {
125 h.logger.Error("Failed to generate locale", "error", err)
126 result.ErrorDetail = fmt.Sprintf("Failed to generate locale: %v", err)
127 return result, err
128 }
129 }
130
131 if err := h.fs.WriteFile(filepath.Join(cmd.MountPoint, "etc", "vconsole.conf"), []byte("KEYMAP="+sysConfig.Keymap()+"\n"), 0644); err != nil {
132 h.logger.Error("Failed to write vconsole.conf", "error", err)
133 result.ErrorDetail = fmt.Sprintf("Failed to write vconsole.conf: %v", err)
134 return result, err
135 }
136
137 // Write X11/Wayland keyboard layout so GUI sessions use the same keymap as the console.
138 if err := h.writeX11KeyboardConf(cmd.MountPoint, sysConfig.Keymap()); err != nil {
139 // Non-fatal: log and continue. Console keymap is already set via vconsole.conf.
140 h.logger.Warn("Failed to write X11 keyboard config", "error", err)
141 }
142
143 groups := strings.Join(usr.Groups(), ",")
144 if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "useradd", "-m", "-G", groups, "-s", usr.Shell(), usr.Username()); err != nil {
145 h.logger.Error("Failed to create user", "error", err)
146 result.ErrorDetail = fmt.Sprintf("Failed to create user: %v", err)
147 return result, err
148 }
149
150 userCreds := fmt.Sprintf("%s:%s\n", usr.Username(), cmd.UserPassword)
151 if err := h.chrExec.ExecuteInChrootWithStdin(ctx, cmd.MountPoint, userCreds, "chpasswd"); err != nil {
152 h.logger.Error("Failed to set user password", "error", err)
153 result.ErrorDetail = fmt.Sprintf("Failed to set user password: %v", err)
154 return result, err
155 }
156
157 if cmd.RootPassword != "" {
158 rootCreds := fmt.Sprintf("root:%s\n", cmd.RootPassword)
159 if err := h.chrExec.ExecuteInChrootWithStdin(ctx, cmd.MountPoint, rootCreds, "chpasswd"); err != nil {
160 h.logger.Error("Failed to set root password", "error", err)
161 result.ErrorDetail = fmt.Sprintf("Failed to set root password: %v", err)
162 return result, err
163 }
164 } else {
165 if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "passwd", "-l", "root"); err != nil {
166 h.logger.Error("Failed to lock root account", "error", err)
167 result.ErrorDetail = fmt.Sprintf("Failed to lock root account: %v", err)
168 return result, err
169 }
170 }
171
172 sudoersDir := filepath.Join(cmd.MountPoint, "etc", "sudoers.d")
173 if err := h.fs.MkdirAll(sudoersDir, 0755); err != nil {
174 h.logger.Error("Failed to create sudoers.d directory", "error", err)
175 result.ErrorDetail = fmt.Sprintf("Failed to create sudoers.d directory: %v", err)
176 return result, err
177 }
178 if err := h.fs.WriteFile(filepath.Join(sudoersDir, "wheel"), []byte(config.SudoersWheelContent), config.SudoersWheelPerms); err != nil {
179 h.logger.Error("Failed to write sudoers", "error", err)
180 result.ErrorDetail = fmt.Sprintf("Failed to write sudoers: %v", err)
181 return result, err
182 }
183
184 if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), cmd.MountPoint, "enable", "NetworkManager"); err != nil {
185 h.logger.Error("Failed to enable NetworkManager", "error", err)
186 result.ErrorDetail = fmt.Sprintf("Failed to enable NetworkManager: %v", err)
187 return result, err
188 }
189
190 if err := h.configureZram(cmd.MountPoint); err != nil {
191 h.logger.Warn("Failed to configure zram", "error", err)
192 }
193
194 // Migrate iwd WiFi credentials from live ISO to NetworkManager profiles in the installed system.
195 if err := h.migrateIWDProfiles(cmd.MountPoint); err != nil {
196 // Non-fatal: log and continue. User can configure WiFi manually after boot.
197 h.logger.Warn("Failed to migrate iwd WiFi profiles", "error", err)
198 }
199
200 h.logger.Info("System configuration completed successfully")
201
202 result.Success = true
203 result.Timezone = sysConfig.Timezone()
204 result.Username = usr.Username()
205
206 return result, nil
207}
208
209// writeX11KeyboardConf writes /etc/X11/xorg.conf.d/00-keyboard.conf so that
210// X11 and Wayland sessions use the same keyboard layout as the console.
211// The XKB layout is derived from the keymap by stripping any variant suffix
212// (e.g. "de-nodeadkeys" → "de", "fr" → "fr").
213func (h *ConfigureSystemHandler) writeX11KeyboardConf(mountPoint, keymap string) error {
214 xkbLayout := xkbLayoutFromKeymap(keymap)
215 if xkbLayout == "" {
216 return nil
217 }
218
219 xorgDir := filepath.Join(mountPoint, "etc", "X11", "xorg.conf.d")
220 if err := h.fs.MkdirAll(xorgDir, 0755); err != nil {
221 return fmt.Errorf("failed to create xorg.conf.d directory: %w", err)
222 }
223
224 content := fmt.Sprintf(`Section "InputClass"
225 Identifier "system-keyboard"
226 MatchIsKeyboard "on"
227 Option "XkbLayout" "%s"
228EndSection
229`, xkbLayout)
230
231 confPath := filepath.Join(xorgDir, "00-keyboard.conf")
232 if err := h.fs.WriteFile(confPath, []byte(content), 0644); err != nil {
233 return fmt.Errorf("failed to write 00-keyboard.conf: %w", err)
234 }
235
236 return nil
237}
238
239// xkbLayoutFromKeymap extracts the XKB layout from a vconsole keymap name.
240// Keymap variants use hyphens (e.g. "de-nodeadkeys"), XKB uses the base part.
241func xkbLayoutFromKeymap(keymap string) string {
242 if keymap == "" {
243 return ""
244 }
245 // Strip variant suffix: "de-nodeadkeys" → "de", "fr" → "fr"
246 if idx := strings.Index(keymap, "-"); idx != -1 {
247 return keymap[:idx]
248 }
249 return keymap
250}
251
252// migrateIWDProfiles reads *.psk files from the live ISO's /var/lib/iwd and
253// writes equivalent NetworkManager keyfile profiles into the installed system.
254// This ensures the system has WiFi connectivity on first boot without manual setup.
255func (h *ConfigureSystemHandler) migrateIWDProfiles(mountPoint string) error {
256 const iwdDir = "/var/lib/iwd"
257
258 entries, err := os.ReadDir(iwdDir)
259 if err != nil {
260 if os.IsNotExist(err) {
261 h.logger.Info("No iwd profiles found (probably ethernet install), skipping WiFi migration")
262 return nil
263 }
264 return fmt.Errorf("failed to read iwd directory: %w", err)
265 }
266
267 nmConnDir := filepath.Join(mountPoint, "etc", "NetworkManager", "system-connections")
268 if err := h.fs.MkdirAll(nmConnDir, 0700); err != nil {
269 return fmt.Errorf("failed to create NM connections directory: %w", err)
270 }
271
272 migrated := 0
273 for _, entry := range entries {
274 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".psk") {
275 continue
276 }
277
278 data, err := os.ReadFile(filepath.Join(iwdDir, entry.Name()))
279 if err != nil {
280 h.logger.Warn("Failed to read iwd profile", "file", entry.Name(), "error", err)
281 continue
282 }
283
284 // SSID is the filename without the .psk extension.
285 ssid := strings.TrimSuffix(entry.Name(), ".psk")
286 // iwd hex-encodes SSIDs containing non-alphanumeric chars as =<hex>.
287 if strings.HasPrefix(ssid, "=") {
288 decoded, err := decodeIWDHexSSID(ssid[1:])
289 if err == nil {
290 ssid = decoded
291 }
292 }
293
294 psk := extractIWDValue(data, "PreSharedKey")
295 passphrase := extractIWDValue(data, "Passphrase")
296
297 // Prefer the raw passphrase for NM; fall back to PreSharedKey (hex PSK).
298 password := passphrase
299 if password == "" {
300 password = psk
301 }
302 if password == "" {
303 h.logger.Warn("iwd profile has no usable password, skipping", "ssid", ssid)
304 continue
305 }
306
307 profile := buildNMWiFiProfile(ssid, password)
308 profilePath := filepath.Join(nmConnDir, ssid+".nmconnection")
309 if err := h.fs.WriteFile(profilePath, []byte(profile), 0600); err != nil {
310 h.logger.Warn("Failed to write NM WiFi profile", "ssid", ssid, "error", err)
311 continue
312 }
313
314 h.logger.Info("Migrated WiFi profile", "ssid", ssid)
315 migrated++
316 }
317
318 if migrated > 0 {
319 h.logger.Info("WiFi profiles migrated to NetworkManager", "count", migrated)
320 }
321 return nil
322}
323
324// extractIWDValue parses a key=value line from an iwd .psk file.
325func extractIWDValue(data []byte, key string) string {
326 scanner := bufio.NewScanner(bytes.NewReader(data))
327 prefix := key + "="
328 for scanner.Scan() {
329 line := strings.TrimSpace(scanner.Text())
330 if strings.HasPrefix(line, prefix) {
331 return strings.TrimPrefix(line, prefix)
332 }
333 }
334 return ""
335}
336
337// decodeIWDHexSSID decodes an iwd hex-encoded SSID (used when SSID contains
338// non-alphanumeric characters). Format: lowercase hex pairs.
339func decodeIWDHexSSID(hex string) (string, error) {
340 if len(hex)%2 != 0 {
341 return "", fmt.Errorf("odd hex length")
342 }
343 b := make([]byte, len(hex)/2)
344 for i := 0; i < len(hex); i += 2 {
345 hi := hexNibble(hex[i])
346 lo := hexNibble(hex[i+1])
347 if hi < 0 || lo < 0 {
348 return "", fmt.Errorf("invalid hex char")
349 }
350 b[i/2] = byte(hi<<4 | lo)
351 }
352 return string(b), nil
353}
354
355func hexNibble(c byte) int {
356 switch {
357 case c >= '0' && c <= '9':
358 return int(c - '0')
359 case c >= 'a' && c <= 'f':
360 return int(c-'a') + 10
361 case c >= 'A' && c <= 'F':
362 return int(c-'A') + 10
363 }
364 return -1
365}
366
367// configureZram writes zram-generator.conf and the matching sysctl tuning file.
368func (h *ConfigureSystemHandler) configureZram(mountPoint string) error {
369 zramDir := filepath.Join(mountPoint, "etc", "systemd")
370 if err := h.fs.MkdirAll(zramDir, 0755); err != nil {
371 return fmt.Errorf("failed to create systemd dir: %w", err)
372 }
373 zramConf := filepath.Join(zramDir, config.FileZramGenerator)
374 if err := h.fs.WriteFile(zramConf, []byte(config.ZramConfigContent), 0644); err != nil {
375 return fmt.Errorf("failed to write zram-generator.conf: %w", err)
376 }
377
378 sysctlDir := filepath.Join(mountPoint, "etc", "sysctl.d")
379 if err := h.fs.MkdirAll(sysctlDir, 0755); err != nil {
380 return fmt.Errorf("failed to create sysctl.d dir: %w", err)
381 }
382 sysctlConf := filepath.Join(sysctlDir, config.FileSysctlZramParams)
383 if err := h.fs.WriteFile(sysctlConf, []byte(config.ZramSysctlContent), 0644); err != nil {
384 return fmt.Errorf("failed to write zram sysctl params: %w", err)
385 }
386
387 return nil
388}
389
390// buildNMWiFiProfile generates a NetworkManager keyfile for a WPA-PSK network.
391func buildNMWiFiProfile(ssid, psk string) string {
392 return fmt.Sprintf(`[connection]
393id=%s
394type=wifi
395autoconnect=true
396
397[wifi]
398ssid=%s
399mode=infrastructure
400
401[wifi-security]
402auth-alg=open
403key-mgmt=wpa-psk
404psk=%s
405
406[ipv4]
407method=auto
408
409[ipv6]
410method=auto
411`, ssid, ssid, psk)
412}