package shares import ( "context" "errors" "fmt" "log" "os/exec" "os/user" "strings" "time" "nasmon/internal/hostrun" ) const ( DefaultSMBConf = "/etc/samba/smb.conf" DefaultExports = "/etc/exports.d/nasmon.exports" // DefaultOwnerUID is the account SMB sessions and squashed NFS clients act // as: the user that owns the data and runs the containers on this NAS. DefaultOwnerUID = "1000" ) // Manager writes the generated config files on the host and reloads the daemons. type Manager struct { Run hostrun.Runner LookPath func(string) (string, error) Owner string // Unix user name for force user UID, GID string SMBConf string Exports string Timeout time.Duration } func New(run hostrun.Runner) *Manager { m := &Manager{Run: run, LookPath: exec.LookPath, UID: DefaultOwnerUID, GID: DefaultOwnerUID, SMBConf: DefaultSMBConf, Exports: DefaultExports, Timeout: 90 * time.Second} m.Owner = m.UID if u, err := user.LookupId(m.UID); err == nil { m.Owner, m.GID = u.Username, u.Gid } return m } func (m *Manager) ctx() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), m.Timeout) } func (m *Manager) installed(bin string) bool { _, err := m.LookPath(bin) return err == nil } func (m *Manager) SMBInstalled() bool { return m.installed("smbd") } func (m *Manager) NFSInstalled() bool { return m.installed("exportfs") } // Apply regenerates both config files and reloads/enables/disables the // services. SMB and NFS are applied independently; errors are joined. func (m *Manager) Apply(c Config) error { var errs []error if m.SMBInstalled() { if err := m.applySMB(c); err != nil { errs = append(errs, fmt.Errorf("SMB: %w", err)) } } if m.NFSInstalled() { if err := m.applyNFS(c); err != nil { errs = append(errs, fmt.Errorf("NFS: %w", err)) } } return errors.Join(errs...) } func (m *Manager) applySMB(c Config) error { ctx, cancel := m.ctx() defer cancel() if err := hostrun.WriteFile(ctx, m.Run, m.SMBConf, RenderSMB(c, m.Owner)); err != nil { return fmt.Errorf("write %s: %w", m.SMBConf, err) } if out, err := m.Run(ctx, "", "testparm", "-s", "--suppress-prompt", m.SMBConf); err != nil { _ = hostrun.Restore(ctx, m.Run, m.SMBConf) return fmt.Errorf("testparm rejected the config, previous file restored: %s", firstLines(out, 4)) } active := c.SMB.Enabled && len(c.SMB.Shares) > 0 if !active { _, err := m.Run(ctx, "", "systemctl", "disable", "--now", "smbd", "nmbd") if err != nil { return fmt.Errorf("stop smbd: %w", err) } log.Printf("shares: SMB disabled (%d shares, enabled=%v)", len(c.SMB.Shares), c.SMB.Enabled) return nil } if _, err := m.Run(ctx, "", "systemctl", "enable", "--now", "smbd", "nmbd"); err != nil { return fmt.Errorf("start smbd: %w", err) } if out, err := m.Run(ctx, "", "smbcontrol", "all", "reload-config"); err != nil { return fmt.Errorf("reload smbd: %s", firstLines(out, 2)) } log.Printf("shares: SMB config applied, %d shares", len(c.SMB.Shares)) return nil } func (m *Manager) applyNFS(c Config) error { ctx, cancel := m.ctx() defer cancel() if err := hostrun.WriteFile(ctx, m.Run, m.Exports, RenderExports(c, m.UID, m.GID)); err != nil { return fmt.Errorf("write %s: %w", m.Exports, err) } active := c.NFS.Enabled && len(c.NFS.Exports) > 0 if !active { if _, err := m.Run(ctx, "", "systemctl", "disable", "--now", "nfs-server"); err != nil { return fmt.Errorf("stop nfs-server: %w", err) } log.Printf("shares: NFS disabled (%d exports, enabled=%v)", len(c.NFS.Exports), c.NFS.Enabled) return nil } if _, err := m.Run(ctx, "", "systemctl", "enable", "--now", "nfs-server"); err != nil { return fmt.Errorf("start nfs-server: %w", err) } if out, err := m.Run(ctx, "", "exportfs", "-ra"); err != nil { _ = hostrun.Restore(ctx, m.Run, m.Exports) _, _ = m.Run(ctx, "", "exportfs", "-ra") return fmt.Errorf("exportfs rejected the exports, previous file restored: %s", firstLines(out, 4)) } log.Printf("shares: NFS exports applied, %d exports", len(c.NFS.Exports)) return nil } func firstLines(s string, n int) string { lines := strings.Split(strings.TrimSpace(s), "\n") if len(lines) > n { lines = lines[:n] } return strings.Join(lines, " | ") } // ---- SMB users // ListUsers returns the Samba accounts (pdbedit -L: "name:uid:full name"). func (m *Manager) ListUsers() ([]string, error) { if !m.SMBInstalled() { return nil, nil } ctx, cancel := m.ctx() defer cancel() out, err := m.Run(ctx, "", "pdbedit", "-L") if err != nil { return nil, err } var users []string for _, l := range strings.Split(out, "\n") { name, _, ok := strings.Cut(strings.TrimSpace(l), ":") if ok && name != "" { users = append(users, name) } } return users, nil } // SetPassword creates the Samba account (and a login-less Unix account if // needed) or changes the password of an existing one. func (m *Manager) SetPassword(name, pass string) error { if err := ValidateUserName(name); err != nil { return err } if len(pass) < 4 || strings.ContainsAny(pass, "\n\r") { return errors.New("password must be at least 4 characters") } ctx, cancel := m.ctx() defer cancel() if _, err := m.Run(ctx, "", "getent", "passwd", name); err != nil { // Unix account is required by Samba; no shell, no home, data owner's group if out, err := m.Run(ctx, "", "useradd", "-M", "-N", "-g", m.GID, "-s", "/usr/sbin/nologin", "-c", "nasmon SMB user", name); err != nil { return fmt.Errorf("create system user: %s", firstLines(out, 2)) } log.Printf("shares: system user %s created for SMB", name) } if out, err := m.Run(ctx, pass+"\n"+pass+"\n", "smbpasswd", "-s", "-a", name); err != nil { return fmt.Errorf("smbpasswd: %s", firstLines(out, 2)) } log.Printf("shares: SMB password set for %s", name) return nil } // DeleteUser removes the Samba account; the Unix account is removed too when // it is one nasmon created (nologin shell). func (m *Manager) DeleteUser(name string) error { if err := ValidateUserName(name); err != nil { return err } ctx, cancel := m.ctx() defer cancel() if out, err := m.Run(ctx, "", "smbpasswd", "-x", name); err != nil { return fmt.Errorf("smbpasswd -x: %s", firstLines(out, 2)) } if ent, err := m.Run(ctx, "", "getent", "passwd", name); err == nil && strings.HasSuffix(strings.TrimSpace(ent), "/usr/sbin/nologin") && name != m.Owner { if out, err := m.Run(ctx, "", "userdel", name); err != nil { return fmt.Errorf("SMB account removed, but userdel failed: %s", firstLines(out, 2)) } } log.Printf("shares: SMB user %s deleted", name) return nil }