package main // Taking over a hand-written fstab line (novox/hq ADR 0263 rule 10, to-be 52 ยง5). // // The line is the operator's, so it is taken over only on a person's word (`confirm`, with why) and only // once the module's own unit pair for the same mount point exists and its automount is armed: the // replacement works first, then the line goes. It is commented out, not deleted, with a dated marker; a // password in it is redacted in the commented line, and the whole file as it was is kept beside it, // readable by root only, because it holds that password. import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "strings" "time" ) // AdoptedPrefix opens a line the module took over. const AdoptedPrefix = "# adopted by the mesh (module mounts) on " // A password's value runs to the next single comma or blank: mount.cifs writes a comma inside it as two. var passwordValue = regexp.MustCompile(`(?i)\b(password2?|pass|passwd)=(?:,,+[^,\s]*|[^,\s])*`) // userPassword is user=name%password, where mount.cifs takes the password after the percent sign. var userPassword = regexp.MustCompile(`(?i)\b(user(?:name)?=[^,\s%]*)%(?:,,+[^,\s]*|[^,\s])*`) // RedactLine replaces every credential's value in a raw fstab line. func RedactLine(line string) string { line = passwordValue.ReplaceAllString(line, "${1}="+Redacted) return userPassword.ReplaceAllString(line, "${1}%"+Redacted) } // Adoption is what taking a line over does, or would do. type Adoption struct { Mountpoint string `json:"mountpoint"` Line int `json:"line"` Was string `json:"was"` Becomes string `json:"becomes"` Copy string `json:"copy,omitempty"` Done bool `json:"done"` Said string `json:"said"` } // PlanAdoption finds the one uncommented fstab line for a mount point and what it becomes. `was` is // redacted; the new text keeps every other line byte for byte. func PlanAdoption(fstab, mountpoint string, now time.Time) (string, Adoption, error) { lines := strings.Split(fstab, "\n") hit := -1 for i, raw := range lines { t := strings.TrimSpace(raw) if t == "" || strings.HasPrefix(t, "#") { continue } f := strings.Fields(t) if len(f) >= 2 && unescape(f[1]) == mountpoint { if hit >= 0 { return "", Adoption{}, fmt.Errorf("/etc/fstab has more than one line for %s (lines %d and %d); take it over by hand", mountpoint, hit+1, i+1) } hit = i } } if hit < 0 { return "", Adoption{}, fmt.Errorf("/etc/fstab has no line for %s", mountpoint) } becomes := AdoptedPrefix + now.Format("2006-01-02") + ": " + RedactLine(lines[hit]) a := Adoption{Mountpoint: mountpoint, Line: hit + 1, Was: RedactLine(lines[hit]), Becomes: becomes} lines[hit] = becomes return strings.Join(lines, "\n"), a, nil } // replacementArmed says whether the module's own pair for a mount point is written and its automount // armed, with why not. func (m *Machine) replacementArmed(ctx context.Context, mountpoint string) error { base := EscapePath(mountpoint) for _, unit := range []string{base + ".mount", base + ".automount"} { if !IsOwnUnit(m, filepath.Join(UnitDir, unit)) { return fmt.Errorf("the module has no unit pair for %s yet (%s is not its own): it takes a line over only once its replacement is in place", mountpoint, unit) } } shown, err := m.Run(ctx, "systemctl", "show", "-p", "ActiveState", "--value", base+".automount") if err != nil { return fmt.Errorf("the service manager did not say whether %s.automount is armed: %v", base, err) } if strings.TrimSpace(shown) != "active" { return fmt.Errorf("%s.automount is not armed (%s): the replacement does not work yet, so the line stays", base, strings.TrimSpace(shown)) } return nil } // AdoptDryRun is what taking the line over would do, changing nothing. func (m *Machine) AdoptDryRun(ctx context.Context, mountpoint string, now time.Time) (Adoption, error) { if err := CheckMountpoint(mountpoint); err != nil { return Adoption{}, err } fstab, err := m.read("/etc/fstab") if err != nil { return Adoption{}, errors.New(unreadable("/etc/fstab", err)) } _, a, err := PlanAdoption(fstab, mountpoint, now) if err != nil { return Adoption{}, err } if err := m.replacementArmed(ctx, mountpoint); err != nil { a.Said = "Would be refused now: " + err.Error() return a, nil } a.Said = fmt.Sprintf("A dry run: line %d of /etc/fstab would be commented out and a copy of the file kept beside it. Nothing changed; call again with confirm and why.", a.Line) return a, nil } // AdoptAsRoot takes the line over: the process's half, run as root through sudo by the tool. It checks // everything again, since the tool's look was the operator account's. func (m *Machine) AdoptAsRoot(ctx context.Context, mountpoint string, now time.Time) (Adoption, error) { if err := CheckMountpoint(mountpoint); err != nil { return Adoption{}, err } if err := m.replacementArmed(ctx, mountpoint); err != nil { return Adoption{}, err } full := filepath.Join(m.Root, "etc/fstab") raw, err := os.ReadFile(full) if err != nil { return Adoption{}, errors.New(unreadable("/etc/fstab", err)) } info, err := os.Stat(full) if err != nil { return Adoption{}, err } next, a, err := PlanAdoption(string(raw), mountpoint, now) if err != nil { return Adoption{}, err } a.Copy = "/etc/fstab.mesh-adopted-" + now.Format("20060102-150405") if err := os.WriteFile(filepath.Join(m.Root, a.Copy), raw, 0o600); err != nil { return Adoption{}, fmt.Errorf("the copy of /etc/fstab was not kept, so nothing changed: %w", err) } tmp := full + ".mesh-new" if err := os.WriteFile(tmp, []byte(next), info.Mode().Perm()); err != nil { return Adoption{}, fmt.Errorf("/etc/fstab was not written, so nothing changed: %w", err) } if err := os.Rename(tmp, full); err != nil { os.Remove(tmp) return Adoption{}, fmt.Errorf("/etc/fstab was not written, so nothing changed: %w", err) } a.Done = true a.Said = fmt.Sprintf("Line %d of /etc/fstab, for %s, is commented out; the file as it was is kept at %s (root only, as it may hold a password).", a.Line, mountpoint, a.Copy) if _, err := m.Run(ctx, "systemctl", "daemon-reload"); err != nil { a.Said += " The service manager did not reload: " + err.Error() } return a, nil } // Adopt is the verb: a dry run without confirm; with it, the root half through sudo. func (m *Machine) Adopt(ctx context.Context, mountpoint string, confirm bool, why string, now time.Time) (Adoption, error) { if !confirm { return m.AdoptDryRun(ctx, mountpoint, now) } if strings.TrimSpace(why) == "" { return Adoption{}, errors.New("say why the line is taken over (`why`): it is the operator's line, and the act is recorded") } self, err := os.Executable() if err != nil { return Adoption{}, err } out, err := m.Escalate(ctx, self, "adopt", mountpoint) var a Adoption if jerr := json.Unmarshal([]byte(strings.TrimSpace(out)), &a); jerr != nil { if err != nil { return Adoption{}, err } return Adoption{}, fmt.Errorf("the root half answered no adoption: %v", jerr) } if !a.Done && a.Said != "" { return a, errors.New(a.Said) } a.Said += " Why: " + strings.TrimSpace(why) + "." return a, nil } // RunAdopt is `mounts adopt `, as root: the answer on stdout as JSON, also when refused. func RunAdopt(args []string) int { if len(args) != 1 { fmt.Fprintln(os.Stderr, "usage: mounts adopt ") return 2 } a, err := Here().AdoptAsRoot(context.Background(), args[0], time.Now()) if err != nil { a = Adoption{Mountpoint: args[0], Said: err.Error()} } raw, _ := json.Marshal(a) fmt.Println(string(raw)) if err != nil { return 1 } return 0 }