package main // Locale, time zone and console keymap as one module (novox/hq to-be 42 Phase 1, research 027/02: // the operator's choice of one module for the three). Measured on 2026-10-04, three machines had // en_US.UTF-8, Europe/Brussels and no keymap, and one had another zone and a German keymap with no // record why; the module brings every machine to the first. // // The locale and the keymap are files the host writes whole. The time zone is not a file the mesh // may write: /etc/localtime is a symbolic link into the zone database, and the mesh creates no // symbolic links (ADR 0012). Writing a copy of the zone there instead would leave every tool that // reads the zone's name from the link (timedatectl among them) answering "n/a", and a module may not // declare an action (ADR 0005). So the module's own binary is run once by the host, as root, as a // step (`set-time-zone`, below): it asks the service manager's time daemon to set the zone, which // makes the distribution's own link — the mesh writes none — and reads it back. import ( "fmt" "os" "regexp" "sort" "strings" ) // What the module declares: its manifest's files and its step's argument, held to these by a test. const ( MeshLang = "en_US.UTF-8" MeshZone = "Europe/Brussels" MeshKeymap = "us" ) // Settings is the machine's locale, time zone and keymap as its own daemons report them. type Settings struct { Locale map[string]string `json:"locale"` Lang string `json:"lang"` LangGenerated bool `json:"lang_generated"` TimeZone string `json:"time_zone"` LocalRTC bool `json:"rtc_in_local_time"` NTP bool `json:"ntp_enabled"` NTPSynced bool `json:"ntp_synchronized"` Keymap string `json:"console_keymap"` X11 map[string]string `json:"x11,omitempty"` // AsDeclared says, for each of the three, whether the machine is what the module declares. AsDeclared map[string]bool `json:"as_declared"` } // Get reads localectl and timedatectl, and whether the locale in force is generated. func (m *Machine) Get() (Settings, error) { s := Settings{Locale: map[string]string{}, X11: map[string]string{}} out, err := m.Out("localectl", "status") if err != nil { return s, err } lc := ParseLocalectl(out) s.Locale, s.Keymap, s.X11 = lc.Locale, lc.Keymap, lc.X11 s.Lang = s.Locale["LANG"] td, err := m.Out("timedatectl", "show") if err != nil { return s, err } kv := keyValues(td, "=") s.TimeZone = kv["Timezone"] s.LocalRTC = kv["LocalRTC"] == "yes" s.NTP = kv["NTP"] == "yes" s.NTPSynced = kv["NTPSynchronized"] == "yes" gen, err := m.Out("locale", "-a") if err != nil { return s, err } s.LangGenerated = generated(lines(gen), s.Lang) s.AsDeclared = map[string]bool{ "locale": s.Lang == MeshLang && s.LangGenerated, "time_zone": s.TimeZone == MeshZone, "keymap": s.Keymap == MeshKeymap, } return s, nil } // Localectl is `localectl status` read: the system locale's variables, the console keymap and the // X11 keyboard settings. type Localectl struct { Locale map[string]string Keymap string X11 map[string]string } // ParseLocalectl reads `localectl status`. The locale's variables continue on lines of their own // beneath its label; "(unset)" is said as empty. func ParseLocalectl(out string) Localectl { l := Localectl{Locale: map[string]string{}, X11: map[string]string{}} label := "" for _, raw := range strings.Split(out, "\n") { line := strings.TrimSpace(raw) if line == "" { continue } value := line if k, v, ok := strings.Cut(line, ": "); ok && !strings.Contains(k, "=") { label, value = strings.TrimSpace(k), strings.TrimSpace(v) } if value == "(unset)" || value == "n/a" { value = "" } switch { case label == "System Locale": if k, v, ok := strings.Cut(value, "="); ok { l.Locale[k] = v } case label == "VC Keymap": l.Keymap = value case strings.HasPrefix(label, "X11 "): if value != "" { l.X11[strings.ToLower(strings.TrimPrefix(label, "X11 "))] = value } } } return l } // normal is a locale's name as glibc compares it: the codeset lowercased without dashes, so // en_US.UTF-8 in a file and en_US.utf8 in `locale -a` are the same locale. func normal(name string) string { lang, codeset, ok := strings.Cut(name, ".") if !ok { return name } mod := "" if c, at, found := strings.Cut(codeset, "@"); found { codeset, mod = c, "@"+at } return lang + "." + strings.ToLower(strings.ReplaceAll(codeset, "-", "")) + mod } func generated(have []string, want string) bool { if want == "" { return false } for _, h := range have { if normal(strings.TrimSpace(h)) == normal(want) { return true } } return false } // Zone is the time zone tool's answer. type Zone struct { TimeZone string `json:"time_zone"` Before string `json:"before,omitempty"` Changed bool `json:"changed"` Declared string `json:"declared"` Zones []string `json:"zones,omitempty"` Count int `json:"zones_matching,omitempty"` Note string `json:"note,omitempty"` } func (m *Machine) zone() (string, error) { out, err := m.Out("timedatectl", "show", "--property=Timezone", "--value") return strings.TrimSpace(out), err } func (m *Machine) zones() ([]string, error) { out, err := m.Out("timedatectl", "list-timezones") return lines(out), err } // TimeZone reads the zone, lists the zones matching a word, or sets one. Setting goes through the // time daemon with sudo -n, which polkit would otherwise refuse to an account without a session. func (m *Machine) TimeZone(set, match string) (Zone, error) { z := Zone{Declared: MeshZone} current, err := m.zone() if err != nil { return z, err } z.TimeZone = current if match != "" { all, err := m.zones() if err != nil { return z, err } for _, name := range all { if strings.Contains(strings.ToLower(name), strings.ToLower(match)) { z.Zones = append(z.Zones, name) } } z.Count = len(z.Zones) if len(z.Zones) > 200 { z.Zones = z.Zones[:200] } } if set == "" { return z, nil } all, err := m.zones() if err != nil { return z, err } if !contains(all, set) { return z, fmt.Errorf("%q is not a time zone this machine knows (timedatectl list-timezones)", set) } z.Before = current if set != current { if _, err := m.Root("timedatectl", "set-timezone", set); err != nil { return z, err } after, err := m.zone() if err != nil { return z, err } if after != set { return z, fmt.Errorf("the time zone was set to %s and reads back as %s", set, after) } z.TimeZone, z.Changed = after, true } if set != MeshZone { z.Note = fmt.Sprintf("the module declares %s; its step sets that zone again whenever the module's bundle changes", MeshZone) } return z, nil } func contains(list []string, want string) bool { for _, s := range list { if s == want { return true } } return false } // Locales is what this machine can, may and does use. type Locales struct { Lang string `json:"lang"` LangGenerated bool `json:"lang_generated"` Generated []string `json:"generated"` Enabled []string `json:"enabled_in_locale_gen"` Available []string `json:"available,omitempty"` AvailableCount int `json:"available_count"` } // Locales reads `locale -a`, the uncommented lines of /etc/locale.gen, and the locales glibc can // generate (/usr/share/i18n/SUPPORTED) — listed when a word narrows them, counted otherwise. func (m *Machine) Locales(match string) (Locales, error) { l := Locales{Generated: []string{}, Enabled: []string{}} gen, err := m.Out("locale", "-a") if err != nil { return l, err } l.Generated = lines(gen) if conf, err := m.ReadFile("/etc/locale.conf"); err == nil { l.Lang = keyValues(string(conf), "=")["LANG"] } else if !os.IsNotExist(err) { return l, err } l.LangGenerated = generated(l.Generated, l.Lang) if gen, err := m.ReadFile("/etc/locale.gen"); err == nil { for _, line := range lines(string(gen)) { if line = strings.TrimSpace(line); !strings.HasPrefix(line, "#") { l.Enabled = append(l.Enabled, line) } } } supported, err := m.ReadFile("/usr/share/i18n/SUPPORTED") if err != nil && !os.IsNotExist(err) { return l, err } for _, line := range lines(string(supported)) { name := strings.Fields(line)[0] l.AvailableCount++ if match != "" && strings.Contains(strings.ToLower(name), strings.ToLower(match)) { l.Available = append(l.Available, strings.TrimSpace(line)) } } return l, nil } // Keymaps is the console keymaps this machine has. type Keymaps struct { Current string `json:"current"` Keymaps []string `json:"keymaps"` Count int `json:"count"` } var keymapName = regexp.MustCompile(`^[A-Za-z0-9_.+-]+$`) // Keymaps lists `localectl list-keymaps`, narrowed to a word when one is given. func (m *Machine) Keymaps(match string) (Keymaps, error) { k := Keymaps{Keymaps: []string{}} status, err := m.Out("localectl", "status") if err != nil { return k, err } k.Current = ParseLocalectl(status).Keymap out, err := m.Out("localectl", "list-keymaps", "--no-pager") if err != nil { return k, err } for _, name := range lines(out) { name = strings.TrimSpace(name) if !keymapName.MatchString(name) { continue } if match == "" || strings.Contains(strings.ToLower(name), strings.ToLower(match)) { k.Keymaps = append(k.Keymaps, name) } } sort.Strings(k.Keymaps) k.Count = len(k.Keymaps) if len(k.Keymaps) > 500 { k.Keymaps = k.Keymaps[:500] } return k, nil } // SetTimeZoneStep is the module's step, run once by the host as root: the zone set through the time // daemon when it differs, and read back. It changes nothing on a machine already in the zone. func (m *Machine) SetTimeZoneStep(zone string) (string, error) { if zone == "" || strings.HasPrefix(zone, "-") || strings.Contains(zone, "..") { return "", fmt.Errorf("%q is not a time zone", zone) } if _, err := m.ReadFile("/usr/share/zoneinfo/" + zone); err != nil { return "", fmt.Errorf("%s is not in this machine's zone database: %v", zone, err) } current, err := m.zone() if err != nil { return "", err } if current == zone { return fmt.Sprintf("the time zone is already %s", zone), nil } if _, err := m.Root("timedatectl", "set-timezone", zone); err != nil { return "", err } after, err := m.zone() if err != nil { return "", err } if after != zone { return "", fmt.Errorf("the time zone was set to %s and reads back as %s", zone, after) } return fmt.Sprintf("the time zone was %s and is now %s", current, zone), nil }