- 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>
This commit is contained in:
@@ -29,12 +29,17 @@ type Manager struct {
|
||||
UID, GID string
|
||||
SMBConf string
|
||||
Exports string
|
||||
Timeout time.Duration
|
||||
// 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, Timeout: 90 * time.Second}
|
||||
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
|
||||
@@ -74,14 +79,19 @@ func (m *Manager) Apply(c Config) error {
|
||||
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 {
|
||||
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 := c.SMB.Enabled && len(c.SMB.Shares) > 0
|
||||
active := len(own.SMB.Shares) > 0 || external > 0
|
||||
if !active {
|
||||
_, err := m.Run(ctx, "", "systemctl", "disable", "--now", "smbd", "nmbd")
|
||||
if err != nil {
|
||||
@@ -96,17 +106,22 @@ func (m *Manager) applySMB(c Config) error {
|
||||
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))
|
||||
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()
|
||||
if err := hostrun.WriteFile(ctx, m.Run, m.Exports, RenderExports(c, m.UID, m.GID)); err != nil {
|
||||
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 := c.NFS.Enabled && len(c.NFS.Exports) > 0
|
||||
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)
|
||||
@@ -122,7 +137,7 @@ func (m *Manager) applyNFS(c Config) error {
|
||||
_, _ = 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))
|
||||
log.Printf("shares: NFS exports applied, %d exports, %d from other services", len(own.NFS.Exports), external)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package shares
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Other services on the host (e.g. RepoISO) publish their own shares through
|
||||
// drop-in files. nasmon includes the SMB drop-ins into the smb.conf it
|
||||
// generates and keeps smbd / nfs-server running while any of them define a
|
||||
// share, even when nasmon itself has none.
|
||||
const (
|
||||
DefaultSMBIncludeDir = "/etc/samba/smb.conf.d"
|
||||
DefaultExportsDir = "/etc/exports.d"
|
||||
)
|
||||
|
||||
// ExternalShare is a share defined outside nasmon (read-only in the UI).
|
||||
type ExternalShare struct {
|
||||
Kind string // smb | nfs
|
||||
Source string // drop-in file name without extension, e.g. "repoiso"
|
||||
Name string // SMB share name (empty for NFS)
|
||||
Path string
|
||||
Clients string // NFS clients with options, as written in the file
|
||||
}
|
||||
|
||||
// IncludeFiles lists the *.conf drop-ins in dir, sorted. A missing dir yields nil.
|
||||
func IncludeFiles(dir string) []string {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
m, _ := filepath.Glob(filepath.Join(dir, "*.conf"))
|
||||
sort.Strings(m)
|
||||
return m
|
||||
}
|
||||
|
||||
func sourceName(file string) string {
|
||||
return strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
|
||||
}
|
||||
|
||||
// ParseSMBDropIn returns the shares ([section] other than [global], [homes],
|
||||
// [printers]) defined in a Samba config fragment.
|
||||
func ParseSMBDropIn(text, source string) []ExternalShare {
|
||||
var out []ExternalShare
|
||||
var cur *ExternalShare
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || line[0] == '#' || line[0] == ';' {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
name := strings.TrimSpace(line[1 : len(line)-1])
|
||||
switch strings.ToLower(name) {
|
||||
case "global", "homes", "printers":
|
||||
cur = nil
|
||||
default:
|
||||
out = append(out, ExternalShare{Kind: "smb", Source: source, Name: name})
|
||||
cur = &out[len(out)-1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
if k, v, ok := strings.Cut(line, "="); ok && strings.EqualFold(strings.TrimSpace(k), "path") {
|
||||
cur.Path = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseExportsFile returns the exports in an exports(5) file.
|
||||
func ParseExportsFile(text, source string) []ExternalShare {
|
||||
var out []ExternalShare
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
var path, rest string
|
||||
if line[0] == '"' {
|
||||
if end := strings.Index(line[1:], `"`); end >= 0 {
|
||||
path, rest = line[1:end+1], line[end+2:]
|
||||
}
|
||||
} else {
|
||||
path, rest, _ = strings.Cut(line, " ")
|
||||
}
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, ExternalShare{Kind: "nfs", Source: source, Path: path, Clients: strings.TrimSpace(rest)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// External reads the SMB drop-ins and the exports.d files of other services.
|
||||
func (m *Manager) External() []ExternalShare {
|
||||
var out []ExternalShare
|
||||
for _, f := range IncludeFiles(m.IncludeDir) {
|
||||
if b, err := os.ReadFile(f); err == nil {
|
||||
out = append(out, ParseSMBDropIn(string(b), sourceName(f))...)
|
||||
}
|
||||
}
|
||||
if m.ExportsDir != "" {
|
||||
files, _ := filepath.Glob(filepath.Join(m.ExportsDir, "*.exports"))
|
||||
sort.Strings(files)
|
||||
for _, f := range files {
|
||||
if filepath.Clean(f) == filepath.Clean(m.Exports) {
|
||||
continue
|
||||
}
|
||||
if b, err := os.ReadFile(f); err == nil {
|
||||
out = append(out, ParseExportsFile(string(b), sourceName(f))...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countKind(ext []ExternalShare, kind string) int {
|
||||
n := 0
|
||||
for _, e := range ext {
|
||||
if e.Kind == kind {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package shares
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const repoisoSMB = `# Managed by repoiso — do not edit
|
||||
|
||||
[iso-pve]
|
||||
path = /tank/iso/export/pve
|
||||
read only = yes
|
||||
guest ok = yes
|
||||
|
||||
[global]
|
||||
; ignored
|
||||
`
|
||||
|
||||
func writeDropIns(t *testing.T) (smbDir, nfsDir string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
smbDir, nfsDir = filepath.Join(root, "smb.conf.d"), filepath.Join(root, "exports.d")
|
||||
for _, d := range []string{smbDir, nfsDir} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
must := func(p, s string) {
|
||||
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
must(filepath.Join(smbDir, "repoiso.conf"), repoisoSMB)
|
||||
must(filepath.Join(smbDir, "notes.txt"), "[ignored]\n")
|
||||
must(filepath.Join(nfsDir, "repoiso.exports"), "# Managed by repoiso\n\"/tank/iso/export/pve\" 10.10.0.0/24(ro,root_squash)\n")
|
||||
must(filepath.Join(nfsDir, "nasmon.exports"), "\"/data\" *(rw)\n")
|
||||
return
|
||||
}
|
||||
|
||||
func TestParseDropIns(t *testing.T) {
|
||||
smb := ParseSMBDropIn(repoisoSMB, "repoiso")
|
||||
if len(smb) != 1 || smb[0].Name != "iso-pve" || smb[0].Path != "/tank/iso/export/pve" || smb[0].Source != "repoiso" {
|
||||
t.Fatalf("smb: %+v", smb)
|
||||
}
|
||||
nfs := ParseExportsFile("# c\n/plain host(ro)\n\"/with space\" 10.0.0.0/8(ro)\n\n", "x")
|
||||
if len(nfs) != 2 || nfs[0].Path != "/plain" || nfs[1].Path != "/with space" || nfs[1].Clients != "10.0.0.0/8(ro)" {
|
||||
t.Fatalf("nfs: %+v", nfs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalSkipsOwnExports(t *testing.T) {
|
||||
smbDir, nfsDir := writeDropIns(t)
|
||||
m := newManager(newFake())
|
||||
m.IncludeDir, m.ExportsDir, m.Exports = smbDir, nfsDir, filepath.Join(nfsDir, "nasmon.exports")
|
||||
ext := m.External()
|
||||
if countKind(ext, "smb") != 1 || countKind(ext, "nfs") != 1 {
|
||||
t.Fatalf("external: %+v", ext)
|
||||
}
|
||||
if got := IncludeFiles(smbDir); len(got) != 1 || !strings.HasSuffix(got[0], "repoiso.conf") {
|
||||
t.Fatalf("includes: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSMBIncludes(t *testing.T) {
|
||||
out := RenderSMB(sample(), "ansible", "/etc/samba/smb.conf.d/repoiso.conf")
|
||||
if !strings.HasSuffix(strings.TrimSpace(out), "include = /etc/samba/smb.conf.d/repoiso.conf") {
|
||||
t.Fatalf("include not at the end:\n%s", out)
|
||||
}
|
||||
if strings.Contains(RenderSMB(sample(), "ansible"), "include =") {
|
||||
t.Fatal("include rendered without drop-ins")
|
||||
}
|
||||
}
|
||||
|
||||
// The bug this guards against: nasmon without own shares disabled smbd and
|
||||
// nfs-server and killed the shares RepoISO publishes for Proxmox.
|
||||
func TestApplyKeepsServicesForOtherServices(t *testing.T) {
|
||||
smbDir, nfsDir := writeDropIns(t)
|
||||
f := newFake()
|
||||
m := newManager(f)
|
||||
m.IncludeDir, m.ExportsDir, m.Exports = smbDir, nfsDir, filepath.Join(nfsDir, "nasmon.exports")
|
||||
if err := m.Apply(Defaults()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.has("systemctl disable") {
|
||||
t.Fatalf("services disabled although other services share: %v", f.calls)
|
||||
}
|
||||
if !f.has("systemctl enable --now smbd nmbd") || !f.has("systemctl enable --now nfs-server") || !f.has("exportfs -ra") {
|
||||
t.Fatalf("services not kept running: %v", f.calls)
|
||||
}
|
||||
wi := f.index("sh -c")
|
||||
if !strings.Contains(f.stdin[f.calls[wi]], "include = "+filepath.Join(smbDir, "repoiso.conf")) {
|
||||
t.Errorf("smb.conf lacks the drop-in include")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDisabledSMBHidesOwnShares(t *testing.T) {
|
||||
smbDir, _ := writeDropIns(t)
|
||||
f := newFake()
|
||||
m := newManager(f)
|
||||
m.IncludeDir = smbDir
|
||||
c := sample()
|
||||
c.SMB.Enabled = false
|
||||
if err := m.Apply(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(f.stdin[f.calls[f.index("sh -c")]], "[documents]") {
|
||||
t.Errorf("disabled nasmon shares still rendered while smbd stays up for others")
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,9 @@ func anyGuest(c Config) bool {
|
||||
|
||||
// RenderSMB produces the complete smb.conf. owner is the Unix account every
|
||||
// SMB session acts as (force user), so files end up with the same owner the
|
||||
// containers use.
|
||||
func RenderSMB(c Config, owner string) string {
|
||||
// containers use. includes are drop-in files of other services appended as
|
||||
// "include =" lines.
|
||||
func RenderSMB(c Config, owner string, includes ...string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s\n\n[global]\n", Header)
|
||||
// Bad User turns unknown accounts (e.g. the Windows login) into guests.
|
||||
@@ -98,6 +99,12 @@ func RenderSMB(c Config, owner string) string {
|
||||
fmt.Fprintf(&b, " %s\n", l)
|
||||
}
|
||||
}
|
||||
if len(includes) > 0 {
|
||||
b.WriteString("\n# Shares of other services (read-only in nasmon)\n")
|
||||
for _, f := range includes {
|
||||
fmt.Fprintf(&b, "include = %s\n", f)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +225,7 @@ func newManager(f *fake) *Manager {
|
||||
m := New(f.run)
|
||||
m.LookPath = f.lookPath
|
||||
m.Owner, m.UID, m.GID = "ansible", "1000", "1000"
|
||||
m.IncludeDir, m.ExportsDir = "", "" // never read the host's drop-ins in tests
|
||||
return m
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user