main 6af0a61

configure_system_handler.go RawHistoryFolder

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}

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