Files
mesh-catalog/modules/mounts/cmd/mounts/adopt.go
T

202 lines
7.5 KiB
Go

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 <mountpoint>`, 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 <mountpoint>")
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
}