Files
a.gritsenkoandClaude Opus 5.5 30dbb00dd1
build / build (push) Successful in 1m43s
Shares of other services and RepoISO integration
- include SMB drop-ins from /etc/samba/smb.conf.d into the generated smb.conf
- keep smbd/nfs-server running while other services (/etc/exports.d, drop-ins) share,
  instead of disabling them when nasmon has no shares of its own
- list those shares read-only on /shares
- settings: RepoISO URL; poll its /api/integration/status, dashboard card, menu link,
  alerts for portal down, stale worker, failed jobs, unmounted publications

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:06:17 +03:00

219 lines
7.1 KiB
Go

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
// IncludeDir holds Samba drop-ins of other services, included into smb.conf;
// ExportsDir is scanned for other services' NFS exports (both: "" = none).
IncludeDir string
ExportsDir 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, IncludeDir: DefaultSMBIncludeDir,
ExportsDir: DefaultExportsDir, 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()
own := c
if !c.SMB.Enabled {
own.SMB.Shares = nil // other services may keep smbd running; don't serve disabled shares
}
external := countKind(m.External(), "smb")
if err := hostrun.WriteFile(ctx, m.Run, m.SMBConf, RenderSMB(own, m.Owner, IncludeFiles(m.IncludeDir)...)); 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 := len(own.SMB.Shares) > 0 || external > 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, %d from other services", len(own.SMB.Shares), external)
return nil
}
func (m *Manager) applyNFS(c Config) error {
ctx, cancel := m.ctx()
defer cancel()
own := c
if !c.NFS.Enabled {
own.NFS.Exports = nil
}
external := countKind(m.External(), "nfs")
if err := hostrun.WriteFile(ctx, m.Run, m.Exports, RenderExports(own, m.UID, m.GID)); err != nil {
return fmt.Errorf("write %s: %w", m.Exports, err)
}
active := len(own.NFS.Exports) > 0 || external > 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, %d from other services", len(own.NFS.Exports), external)
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
}