From 30dbb00dd156f4624b2733d6f9f4612909ccfed3 Mon Sep 17 00:00:00 2001 From: "a.gritsenko" Date: Sat, 3 Oct 2026 15:06:17 +0300 Subject: [PATCH] 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 --- README.md | 4 + internal/alert/repoiso_test.go | 35 +++++++ internal/alert/rules.go | 28 ++++++ internal/collect/repoiso.go | 54 +++++++++++ internal/collect/repoiso_test.go | 29 ++++++ internal/config/config.go | 29 ++++-- internal/sched/sched.go | 10 +- internal/shares/apply.go | 31 +++++-- internal/shares/external.go | 128 ++++++++++++++++++++++++++ internal/shares/external_test.go | 111 ++++++++++++++++++++++ internal/shares/render.go | 11 ++- internal/shares/shares_test.go | 1 + internal/web/handlers.go | 1 + internal/web/integration_test.go | 87 +++++++++++++++++ internal/web/server.go | 34 +++++++ internal/web/shares.go | 2 + internal/web/shares_test.go | 1 + internal/web/templates/dashboard.html | 20 ++++ internal/web/templates/layout.html | 1 + internal/web/templates/settings.html | 11 +++ internal/web/templates/shares.html | 21 +++++ packaging/README.md | 26 ++++++ 22 files changed, 656 insertions(+), 19 deletions(-) create mode 100644 internal/alert/repoiso_test.go create mode 100644 internal/collect/repoiso.go create mode 100644 internal/collect/repoiso_test.go create mode 100644 internal/shares/external.go create mode 100644 internal/shares/external_test.go create mode 100644 internal/web/integration_test.go diff --git a/README.md b/README.md index 8e72d81..b911657 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ share management from the UI. * Service: `systemctl status nasmon` / `journalctl -u nasmon -f` * JSON API: `/api/status`, `/api/history?hours=24`, `/api/disks//history?days=30` +Integrates with RepoISO on the same host: shares of other services +(`/etc/samba/smb.conf.d`, `/etc/exports.d`) are included and kept running, +and RepoISO health is shown on the dashboard and alerted on. + No authentication — designed for a LAN-only NAS (the `/shares` page can create SMB/NFS users and exports, so keep it off the public internet). diff --git a/internal/alert/repoiso_test.go b/internal/alert/repoiso_test.go new file mode 100644 index 0000000..9e77cf5 --- /dev/null +++ b/internal/alert/repoiso_test.go @@ -0,0 +1,35 @@ +package alert + +import ( + "testing" + "time" + + "nasmon/internal/collect" +) + +func TestRepoISORules(t *testing.T) { + base := Input{Now: time.Now()} + if ev := Evaluate(base); len(ev) != 0 { + t.Fatalf("integration off must not alert: %v", keys(ev)) + } + + in := base + in.RepoISO = &collect.RepoISO{URL: "http://127.0.0.1", Error: "connection refused"} + ev := Evaluate(in) + if !has(ev, "repoiso:api:down") || len(ev) != 1 || ev[0].Level != LevelCrit { + t.Fatalf("down: %v", keys(ev)) + } + + in.RepoISO = &collect.RepoISO{Reachable: true, WorkerAlive: false, JobsFailed24h: 2, PublicationsError: 1} + ev = Evaluate(in) + for _, k := range []string{"repoiso:worker:stale", "repoiso:jobs:failed", "repoiso:publications:error"} { + if !has(ev, k) { + t.Errorf("missing %s in %v", k, keys(ev)) + } + } + + in.RepoISO = &collect.RepoISO{Reachable: true, WorkerAlive: true} + if ev = Evaluate(in); len(ev) != 0 { + t.Fatalf("healthy RepoISO alerted: %v", keys(ev)) + } +} diff --git a/internal/alert/rules.go b/internal/alert/rules.go index 05092ce..4228ae5 100644 --- a/internal/alert/rules.go +++ b/internal/alert/rules.go @@ -40,6 +40,8 @@ type Input struct { HasCPU bool RootFS collect.FSUsage RootOK bool + // RepoISO is the polled RepoISO health (nil when the integration is off). + RepoISO *collect.RepoISO // Frozen lists key prefixes whose open alerts must be left untouched // because no fresh data was available for them in this cycle. Frozen []string @@ -194,10 +196,36 @@ func Evaluate(in Input) []Event { } } + if r := in.RepoISO; r != nil { + switch { + case !r.Reachable: + add("repoiso:api:down", LevelCrit, "RepoISO is not responding", + fmt.Sprintf("%s/api/integration/status: %s. Check: systemctl status repoiso-api nginx", r.URL, r.Error)) + case !r.WorkerAlive: + add("repoiso:worker:stale", LevelWarn, "RepoISO worker is not running", + fmt.Sprintf("Last worker heartbeat: %s. Downloads, mounts and Proxmox exports are not processed. Check: systemctl status repoiso-worker", orNever(r.WorkerHeartbeat))) + } + if r.Reachable && r.JobsFailed24h > 0 { + add("repoiso:jobs:failed", LevelWarn, fmt.Sprintf("RepoISO: %d failed job(s) in 24 h", r.JobsFailed24h), + "See the Jobs page of RepoISO for the errors.") + } + if r.Reachable && r.PublicationsError > 0 { + add("repoiso:publications:error", LevelWarn, fmt.Sprintf("RepoISO: %d publication(s) not mounted", r.PublicationsError), + "See the Publications page of RepoISO; remount or unpublish the image.") + } + } + sort.Slice(ev, func(i, j int) bool { return ev[i].Key < ev[j].Key }) return ev } +func orNever(s string) string { + if s == "" { + return "never" + } + return s +} + func humanBytes(b uint64) string { const unit = 1024 if b < unit { diff --git a/internal/collect/repoiso.go b/internal/collect/repoiso.go new file mode 100644 index 0000000..144e9ee --- /dev/null +++ b/internal/collect/repoiso.go @@ -0,0 +1,54 @@ +package collect + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// RepoISO is the health summary of a RepoISO portal running on the same host +// (GET /api/integration/status). Reachable=false means the API did not answer. +type RepoISO struct { + URL string `json:"url"` + Reachable bool `json:"reachable"` + Error string `json:"error,omitempty"` + CheckedAt time.Time `json:"checked_at"` + Version string `json:"version"` + WorkerAlive bool `json:"worker_alive"` + WorkerHeartbeat string `json:"worker_heartbeat"` + Images int `json:"images"` + ImagesBytes uint64 `json:"images_bytes"` + ImagesPending int `json:"images_pending"` + ImagesError int `json:"images_error"` + Publications int `json:"publications"` + PublicationsError int `json:"publications_error"` + JobsActive int `json:"jobs_active"` + JobsFailed24h int `json:"jobs_failed_24h"` + Exports int `json:"exports"` +} + +var repoisoClient = &http.Client{Timeout: 5 * time.Second} + +// FetchRepoISO queries the RepoISO status endpoint; errors are reported in the result. +func FetchRepoISO(base string) RepoISO { + base = strings.TrimRight(base, "/") + r := RepoISO{URL: base, CheckedAt: time.Now()} + resp, err := repoisoClient.Get(base + "/api/integration/status") + if err != nil { + r.Error = err.Error() + return r + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + r.Error = fmt.Sprintf("HTTP %d", resp.StatusCode) + return r + } + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + r.Error = "bad response: " + err.Error() + return r + } + r.URL, r.Reachable, r.Error = base, true, "" + return r +} diff --git a/internal/collect/repoiso_test.go b/internal/collect/repoiso_test.go new file mode 100644 index 0000000..f9c8449 --- /dev/null +++ b/internal/collect/repoiso_test.go @@ -0,0 +1,29 @@ +package collect + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestFetchRepoISO(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/integration/status" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"version":"0.1.3","worker_alive":true,"images":5,"images_bytes":1024,"jobs_failed_24h":1}`)) + })) + defer srv.Close() + r := FetchRepoISO(srv.URL + "/") + if !r.Reachable || r.Version != "0.1.3" || !r.WorkerAlive || r.Images != 5 || r.JobsFailed24h != 1 || r.URL != srv.URL { + t.Fatalf("unexpected %+v", r) + } + if r := FetchRepoISO(srv.URL + "/missing"); r.Reachable || r.Error != "HTTP 404" { + t.Fatalf("404 not reported: %+v", r) + } + srv.Close() + if r := FetchRepoISO(srv.URL); r.Reachable || r.Error == "" { + t.Fatalf("closed server reported reachable: %+v", r) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 78b3bae..e287d15 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,15 +40,23 @@ type SelfTests struct { Hour int `json:"hour"` // local hour of day when tests may be started } +// Integrations links nasmon with other services on the same host. +type Integrations struct { + // RepoISOURL is the RepoISO portal base URL nasmon polls for health + // (e.g. http://127.0.0.1); empty disables the integration. + RepoISOURL string `json:"repoiso_url"` +} + type Settings struct { - SMTP SMTP `json:"smtp"` - AlertsEnabled bool `json:"alerts_enabled"` - NotifyResolved bool `json:"notify_resolved"` - RemindHours int `json:"remind_hours"` - SmartIntervalMin int `json:"smart_interval_min"` - SmartSkipStandby bool `json:"smart_skip_standby"` - Thresholds Thresholds `json:"thresholds"` - SelfTests SelfTests `json:"self_tests"` + SMTP SMTP `json:"smtp"` + AlertsEnabled bool `json:"alerts_enabled"` + NotifyResolved bool `json:"notify_resolved"` + RemindHours int `json:"remind_hours"` + SmartIntervalMin int `json:"smart_interval_min"` + SmartSkipStandby bool `json:"smart_skip_standby"` + Thresholds Thresholds `json:"thresholds"` + SelfTests SelfTests `json:"self_tests"` + Integrations Integrations `json:"integrations"` } func Defaults() Settings { @@ -102,6 +110,11 @@ func (s *Settings) Validate() error { if s.SelfTests.ShortDays < 0 || s.SelfTests.LongDays < 0 { return errors.New("self-test periods must be >= 0") } + u := strings.TrimRight(strings.TrimSpace(s.Integrations.RepoISOURL), "/") + if u != "" && !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { + return errors.New("RepoISO URL must start with http:// or https://") + } + s.Integrations.RepoISOURL = u return nil } diff --git a/internal/sched/sched.go b/internal/sched/sched.go index 1f4de26..e335411 100644 --- a/internal/sched/sched.go +++ b/internal/sched/sched.go @@ -54,6 +54,7 @@ type Status struct { NextSmartCheck time.Time `json:"next_smart_check"` SmartRunning bool `json:"smart_running"` LastMailErr string `json:"last_mail_err,omitempty"` + RepoISO *collect.RepoISO `json:"repoiso,omitempty"` } type Scheduler struct { @@ -243,6 +244,11 @@ func (s *Scheduler) collectStorage() { root, _ := collect.ReadFS("/") alerts, _ := s.st.ActiveAlerts() services := collect.ReadShareServices() + var repoiso *collect.RepoISO + if url := s.cfg.Get().Integrations.RepoISOURL; url != "" { + r := collect.FetchRepoISO(url) + repoiso = &r + } s.mu.Lock() defer s.mu.Unlock() @@ -264,6 +270,7 @@ func (s *Scheduler) collectStorage() { s.status.RootFS = root s.status.Alerts = alerts s.status.Services = services + s.status.RepoISO = repoiso } // buildViews merges lsblk disks with cached SMART data; caller holds s.mu. @@ -419,7 +426,8 @@ func (s *Scheduler) evaluate() { Disks: nil, Pools: st.Pools, PoolsOK: st.PoolsErr == "", CPUTemp: st.CPUTemp, HasCPU: st.HasCPUTemp, RootFS: st.RootFS, RootOK: st.RootFS.Total > 0, - Prev: map[string]*collect.Smart{}, + Prev: map[string]*collect.Smart{}, + RepoISO: st.RepoISO, } for _, v := range st.Disks { in.Disks = append(in.Disks, v.Disk) diff --git a/internal/shares/apply.go b/internal/shares/apply.go index 0732be0..4f50183 100644 --- a/internal/shares/apply.go +++ b/internal/shares/apply.go @@ -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 } diff --git a/internal/shares/external.go b/internal/shares/external.go new file mode 100644 index 0000000..34f6c9d --- /dev/null +++ b/internal/shares/external.go @@ -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 +} diff --git a/internal/shares/external_test.go b/internal/shares/external_test.go new file mode 100644 index 0000000..e5ca079 --- /dev/null +++ b/internal/shares/external_test.go @@ -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") + } +} diff --git a/internal/shares/render.go b/internal/shares/render.go index d0a19a8..94b5e5e 100644 --- a/internal/shares/render.go +++ b/internal/shares/render.go @@ -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() } diff --git a/internal/shares/shares_test.go b/internal/shares/shares_test.go index f9c5ba1..9a029d9 100644 --- a/internal/shares/shares_test.go +++ b/internal/shares/shares_test.go @@ -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 } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 9eacca0..4cbeaf4 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -465,6 +465,7 @@ func formToSettings(r *http.Request, old config.Settings) config.Settings { c.SelfTests.ShortDays = num("st_short_days", old.SelfTests.ShortDays) c.SelfTests.LongDays = num("st_long_days", old.SelfTests.LongDays) c.SelfTests.Hour = num("st_hour", old.SelfTests.Hour) + c.Integrations.RepoISOURL = strings.TrimSpace(r.FormValue("repoiso_url")) return c } diff --git a/internal/web/integration_test.go b/internal/web/integration_test.go new file mode 100644 index 0000000..e25ed7f --- /dev/null +++ b/internal/web/integration_test.go @@ -0,0 +1,87 @@ +package web + +import ( + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "nasmon/internal/collect" + "nasmon/internal/shares" +) + +func TestBrowserURL(t *testing.T) { + cases := []struct{ cfg, host, want string }{ + {"", "nas:8090", ""}, + {"http://127.0.0.1", "10.10.0.129:8090", "http://10.10.0.129"}, + {"http://localhost:8080/", "nas.lan:8090", "http://nas.lan:8080/"}, + {"https://repo.example.com", "10.0.0.1:8090", "https://repo.example.com"}, + {"http://127.0.0.1", "[fe80::1]:8090", "http://[fe80::1]"}, + } + for _, c := range cases { + if got := browserURL(c.cfg, c.host); got != c.want { + t.Errorf("browserURL(%q, %q) = %q, want %q", c.cfg, c.host, got, c.want) + } + } +} + +func TestRepoISOIntegrationUI(t *testing.T) { + srv, sc, _ := newTestServer(t) + // settings: save the URL, menu link appears + rec := postForm(srv, "/settings", url.Values{"smtp_port": {"587"}, "smtp_security": {"starttls"}, + "smart_interval": {"30"}, "repoiso_url": {"http://127.0.0.1/"}}) + if rec.Code != 200 || srv.cfg.Get().Integrations.RepoISOURL != "http://127.0.0.1" { + t.Fatalf("settings not saved: %d %q", rec.Code, srv.cfg.Get().Integrations.RepoISOURL) + } + rec = postForm(srv, "/settings", url.Values{"smtp_port": {"587"}, "smtp_security": {"starttls"}, + "smart_interval": {"30"}, "repoiso_url": {"ftp://x"}}) + if !strings.Contains(rec.Body.String(), "RepoISO URL must start") { + t.Errorf("bad URL accepted") + } + + st := sc.Status() + st.RepoISO = &collect.RepoISO{URL: "http://127.0.0.1", Reachable: true, WorkerAlive: true, Version: "0.1.3", + Images: 61, ImagesBytes: 350 << 30, Publications: 2, JobsFailed24h: 1, Exports: 1} + sc.SetStatus(st) + rec = httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + req.Host = "10.10.0.129:8090" + srv.ServeHTTP(rec, req) + body := rec.Body.String() + for _, want := range []string{`href="http://10.10.0.129"`, "RepoISO ↗", `id="repoiso"`, "v0.1.3", "1 failed in 24 h"} { + if !strings.Contains(body, want) { + t.Errorf("dashboard lacks %q", want) + } + } + + st.RepoISO = &collect.RepoISO{URL: "http://127.0.0.1", Error: "connection refused"} + sc.SetStatus(st) + rec = httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + if !strings.Contains(rec.Body.String(), "not responding") { + t.Errorf("unreachable RepoISO not shown") + } +} + +func TestSharesPageListsExternal(t *testing.T) { + srv, _, _ := newTestServer(t) + f := &shareRunner{} + withShareRunner(srv, f) + dir := t.TempDir() + smbDir, nfsDir := filepath.Join(dir, "smb.conf.d"), filepath.Join(dir, "exports.d") + _ = os.MkdirAll(smbDir, 0o755) + _ = os.MkdirAll(nfsDir, 0o755) + _ = os.WriteFile(filepath.Join(smbDir, "repoiso.conf"), []byte("[iso-pve]\n path = /tank/iso/export/pve\n"), 0o644) + _ = os.WriteFile(filepath.Join(nfsDir, "repoiso.exports"), []byte("\"/tank/iso/export/pve\" 10.10.0.0/24(ro)\n"), 0o644) + srv.shm.IncludeDir, srv.shm.ExportsDir, srv.shm.Exports = smbDir, nfsDir, filepath.Join(nfsDir, shares.DefaultExports) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/shares", nil)) + body := rec.Body.String() + for _, want := range []string{`id="external"`, "iso-pve", "/tank/iso/export/pve", "10.10.0.0/24(ro)", "RepoISO"} { + if !strings.Contains(body, want) { + t.Errorf("shares page lacks %q", want) + } + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 2671e69..9a197d4 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -8,7 +8,9 @@ import ( "io/fs" "log" "math" + "net" "net/http" + "net/url" "path/filepath" "strings" "sync" @@ -105,6 +107,7 @@ type pageData struct { Data any Flash string Error string + RepoISO string // browser URL of the RepoISO portal ("" = integration off) } // render writes a full page, or only the "content" block when ?partial=1 is @@ -112,6 +115,7 @@ type pageData struct { func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, pd pageData) { pd.Page = name pd.Partial = r.URL.Query().Get("partial") == "1" + pd.RepoISO = browserURL(s.cfg.Get().Integrations.RepoISOURL, r.Host) if pd.Alerts == 0 { pd.Alerts = len(s.sch.Status().Alerts) } @@ -288,3 +292,33 @@ func sparkline(vals []float64, max float64, class string) template.HTML { ``, class, int(w), int(h), int(h), p, int(w), int(h), p)) } + +// browserURL turns the configured RepoISO address into one the user's browser +// can open: a loopback host is replaced by the host the browser used for nasmon. +func browserURL(cfg, reqHost string) string { + if cfg == "" { + return "" + } + u, err := url.Parse(cfg) + if err != nil || u.Host == "" { + return cfg + } + switch u.Hostname() { + case "127.0.0.1", "localhost", "::1": + host := reqHost + if h, _, err := net.SplitHostPort(reqHost); err == nil { + host = h + } + if host == "" { + return cfg + } + if strings.Contains(host, ":") { + host = "[" + host + "]" + } + if p := u.Port(); p != "" { + host += ":" + p + } + u.Host = host + } + return u.String() +} diff --git a/internal/web/shares.go b/internal/web/shares.go index b620a91..3b81a94 100644 --- a/internal/web/shares.go +++ b/internal/web/shares.go @@ -39,6 +39,7 @@ type sharesVM struct { NForm nfsForm ApplyErr string // last Apply failure: config saved but not live Squashes []string + External []shares.ExternalShare // shares of other services (drop-ins), read-only } func (s *Server) applyErr() string { @@ -96,6 +97,7 @@ func (s *Server) sharesView(st sched.Status, form smbForm, nform nfsForm) shares vm.NForm.Squash = "root" } } + vm.External = s.shm.External() if u, err := s.shm.ListUsers(); err == nil { vm.Users = u } else { diff --git a/internal/web/shares_test.go b/internal/web/shares_test.go index 58b4c0c..0b98d93 100644 --- a/internal/web/shares_test.go +++ b/internal/web/shares_test.go @@ -59,6 +59,7 @@ func withShareRunner(srv *Server, f *shareRunner) { srv.shm = shares.New(f.run) srv.shm.LookPath = func(string) (string, error) { return "/usr/sbin/x", nil } srv.shm.Owner, srv.shm.UID, srv.shm.GID = "ansible", "1000", "1000" + srv.shm.IncludeDir, srv.shm.ExportsDir = "", "" } func TestSharesSMBFlow(t *testing.T) { diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 3080ec7..9407818 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -104,6 +104,26 @@ +{{with .S.RepoISO}} +
+

RepoISO {{if $.RepoISO}}open ↗{{end}} + {{if not .Reachable}}not responding + {{else if not .WorkerAlive}}worker stopped + {{else}}running{{end}} + {{if .Version}}v{{.Version}}{{end}}

+ {{if not .Reachable}}
{{.URL}}/api/integration/status: {{.Error}}
{{else}} +
+ + + + + + + +
Images{{.Images}}{{bytes .ImagesBytes}}{{if .ImagesPending}} · {{.ImagesPending}} being checked{{end}}{{if .ImagesError}} · {{.ImagesError}} with errors{{end}}
Publications{{.Publications}}{{if .PublicationsError}}{{.PublicationsError}} not mounted{{else}}HTTP repositories / file trees{{end}}
Jobs{{.JobsActive}}{{if .JobsActive}}active{{else}}idle{{end}}{{if .JobsFailed24h}} · {{.JobsFailed24h}} failed in 24 h{{end}}
Proxmox exports{{.Exports}}NFS / SMB shares — listed on Shares
Worker{{if .WorkerHeartbeat}}last heartbeat {{.WorkerHeartbeat}}{{else}}no heartbeat yet{{end}}
{{end}} +
+{{end}} +

Datasets

diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index d243504..871efc6 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -16,6 +16,7 @@ SharesAlerts{{if .Alerts}} {{.Alerts}}{{end}}Settings + {{if .RepoISO}}RepoISO ↗{{end}} diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index 2898a02..b2ad58f 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -67,6 +67,17 @@

Tests start only for disks that are awake. A long test on a large HDD takes many hours; disk stays usable.

+
+

Integrations

+
+ +
+

RepoISO (ISO library, apt/dnf repositories, Proxmox ISO exports) on this host. nasmon polls + <url>/api/integration/status every minute, shows a dashboard card, alerts when the portal + or its worker is down, and adds a menu link. Use the local address (http://127.0.0.1) — the + status endpoint only answers on loopback. Empty disables the integration.

+
+
diff --git a/internal/web/templates/shares.html b/internal/web/templates/shares.html index d98053d..a9ca464 100644 --- a/internal/web/templates/shares.html +++ b/internal/web/templates/shares.html @@ -140,5 +140,26 @@

Linux: sudo mount -t nfs {{.Host}}:/nas_pool_1_8_gb/documents /mnt/nas; list exports with showmount -e {{.Host}}.

+ +{{if .External}} +
+

Shares of other services read-only

+

Defined by other software on this host in /etc/samba/smb.conf.d/*.conf + (included into smb.conf) and /etc/exports.d/*.exports. Manage them in that software; + nasmon keeps smbd / nfs-server running while any of them exist.

+
+ + + {{range .External}} + + + + + + {{end}} + +
TypeSourceNamePathClients
{{if eq .Kind "smb"}}SMB{{else}}NFS{{end}}{{if eq .Source "repoiso"}}{{if $.RepoISO}}RepoISO{{else}}RepoISO{{end}}{{else}}{{.Source}}{{end}}{{if .Name}}\\{{$.Data.Host}}\{{.Name}}{{else}}—{{end}}{{.Path}}{{.Clients}}
+
+{{end}} {{end}} {{end}} diff --git a/packaging/README.md b/packaging/README.md index 4a6c559..0036075 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -47,3 +47,29 @@ Alerts: the × on an open alert acknowledges it (hidden and not mailed until the problem changes or clears); the × on a resolved alert removes it from the log. Resolved alerts older than 7 days are hidden by default, older than 90 days deleted. + +## Shares of other services + +Other software on the host can publish shares without fighting over the +config files nasmon owns: + +* SMB: drop a fragment into /etc/samba/smb.conf.d/.conf. nasmon appends + "include = " for every *.conf there to the smb.conf it generates. +* NFS: write /etc/exports.d/.exports. + +nasmon lists these shares read-only on /shares and keeps smbd / nfs-server +enabled while any of them exist, even with no nasmon shares (previously it +disabled the daemons and took such shares down). When SMB is switched off in +nasmon, only nasmon's own shares are removed. + +## RepoISO integration + +Settings → Integrations → RepoISO URL (e.g. http://127.0.0.1). nasmon then +polls /api/integration/status every minute and + +* shows a RepoISO card on the dashboard (images, publications, jobs, Proxmox + exports, worker heartbeat) and a "RepoISO" menu link; +* alerts: crit when the portal does not respond, warn when its worker has no + heartbeat, when jobs failed in the last 24 h or publications are not mounted. + +The status endpoint answers only on loopback, so use the local address.