4 Commits
Author SHA1 Message Date
a.gritsenkoandClaude Opus 5.5 42cfb43e2e Remove the RepoISO integration; keep generic support for shares of other services
build / build (push) Successful in 1m5s
RepoISO now manages the NAS itself, so nasmon no longer polls it (settings field, dashboard card,
menu link and repoiso:* alerts are gone). Drop-in SMB includes, read-only listing and keeping
smbd/nfs-server running for other software's shares stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:05:16 +03:00
a.gritsenkoandClaude Opus 5.5 30dbb00dd1 Shares of other services and RepoISO integration
build / build (push) Successful in 1m43s
- 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
a.gritsenkoandClaude Opus 5.5 661a862f8d Revert automatic releases on every push to main
build / build (push) Successful in 1m1s
Releases are published only for manually pushed v* tags again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:33:35 +03:00
a.gritsenkoandClaude Opus 5.5 0eab036de7 README: document automatic releases
build / build (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:31:24 +03:00
12 changed files with 358 additions and 58 deletions
+12 -48
View File
@@ -1,8 +1,5 @@
name: build name: build
# Every push to main is released: it gets the next patch tag (v0.1.3 -> v0.1.4)
# and a Gitea release with the deb/rpm. Pushing a v* tag by hand releases that
# exact version instead (use it to bump minor/major).
on: on:
push: push:
branches: ["**"] branches: ["**"]
@@ -17,8 +14,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@v4
with:
fetch-depth: 0 # tags + history for the version and release notes
- uses: https://gitea.com/actions/setup-go@v5 - uses: https://gitea.com/actions/setup-go@v5
with: with:
@@ -29,27 +24,6 @@ jobs:
go vet ./... go vet ./...
go test ./... go test ./...
- name: release version
id: ver
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
run: |
set -euo pipefail
git fetch --tags --force -q
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
tag="${GITHUB_REF#refs/tags/}"
else
# re-run of an already released commit keeps its tag
tag=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)
if [ -z "$tag" ]; then
last=$(git tag | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)
IFS=. read -r major minor patch <<<"${last:-v0.0.0}"
tag="${major}.${minor}.$((patch + 1))"
fi
fi
echo "release $tag"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "VERSION=${tag#v}" >> "$GITHUB_ENV"
- name: install nfpm - name: install nfpm
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest
@@ -68,41 +42,31 @@ jobs:
dist/*.rpm dist/*.rpm
- name: publish release - name: publish release
if: steps.ver.outputs.tag != '' if: startsWith(github.ref, 'refs/tags/')
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.api_url }}/repos/${{ github.repository }} API: ${{ github.api_url }}/repos/${{ github.repository }}
TAG: ${{ steps.ver.outputs.tag }}
run: | run: |
set -euo pipefail set -euo pipefail
auth="Authorization: token $GITEA_TOKEN" tag="${GITHUB_REF#refs/tags/}"
# release notes: commit subjects since the previous release tag release_id=$(curl -sf -X POST "$API/releases" \
prev=$(git tag --merged HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -vx "$TAG" | sort -V | tail -1 || true) -H "Authorization: token $GITEA_TOKEN" \
git log --no-merges --pretty='- %s' ${prev:+"$prev"..}HEAD > notes.md -H "Content-Type: application/json" \
body=$(python3 -c 'import json,os,sys; print(json.dumps({"tag_name": os.environ["TAG"], "name": os.environ["TAG"], "target_commitish": os.environ["GITHUB_SHA"], "body": open("notes.md").read()}))') -d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
# creating the release also creates the tag on this commit
release_id=$(curl -sf -X POST "$API/releases" -H "$auth" \
-H "Content-Type: application/json" -d "$body" \
| grep -oP '"id":\s*\K[0-9]+' | head -1) || \ | grep -oP '"id":\s*\K[0-9]+' | head -1) || \
release_id=$(curl -sf "$API/releases/tags/$TAG" -H "$auth" \ release_id=$(curl -sf "$API/releases/tags/$tag" \
-H "Authorization: token $GITEA_TOKEN" \
| grep -oP '"id":\s*\K[0-9]+' | head -1) | grep -oP '"id":\s*\K[0-9]+' | head -1)
if [ -z "$release_id" ]; then if [ -z "$release_id" ]; then
echo "could not create or find a release for tag $TAG" >&2 echo "could not create or find a release for tag $tag" >&2
exit 1 exit 1
fi fi
existing=$(curl -sf "$API/releases/$release_id/assets" -H "$auth" || true)
for f in dist/*.deb dist/*.rpm; do for f in dist/*.deb dist/*.rpm; do
[ -e "$f" ] || continue [ -e "$f" ] || continue
name=$(basename "$f") curl -sf -X POST "$API/releases/$release_id/assets?name=$(basename "$f")" \
if grep -q "\"name\":\"$name\"" <<<"$existing"; then -H "Authorization: token $GITEA_TOKEN" \
echo "$name already attached" -F "attachment=@${f}"
continue
fi
curl -sf -X POST "$API/releases/$release_id/assets?name=$name" \
-H "$auth" -F "attachment=@${f}"
done done
echo "released $TAG: ${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG"
+4
View File
@@ -11,6 +11,10 @@ share management from the UI.
* Service: `systemctl status nasmon` / `journalctl -u nasmon -f` * Service: `systemctl status nasmon` / `journalctl -u nasmon -f`
* JSON API: `/api/status`, `/api/history?hours=24`, `/api/disks/<serial>/history?days=30` * JSON API: `/api/status`, `/api/history?hours=24`, `/api/disks/<serial>/history?days=30`
Shares of other software on the same host (`/etc/samba/smb.conf.d/*.conf`,
`/etc/exports.d/*.exports`) are included into the generated config, listed
read-only and keep smbd / nfs-server running.
No authentication — designed for a LAN-only NAS (the `/shares` page can 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). create SMB/NFS users and exports, so keep it off the public internet).
+22 -7
View File
@@ -29,12 +29,17 @@ type Manager struct {
UID, GID string UID, GID string
SMBConf string SMBConf string
Exports 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 Timeout time.Duration
} }
func New(run hostrun.Runner) *Manager { func New(run hostrun.Runner) *Manager {
m := &Manager{Run: run, LookPath: exec.LookPath, UID: DefaultOwnerUID, GID: DefaultOwnerUID, 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 m.Owner = m.UID
if u, err := user.LookupId(m.UID); err == nil { if u, err := user.LookupId(m.UID); err == nil {
m.Owner, m.GID = u.Username, u.Gid 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 { func (m *Manager) applySMB(c Config) error {
ctx, cancel := m.ctx() ctx, cancel := m.ctx()
defer cancel() 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) return fmt.Errorf("write %s: %w", m.SMBConf, err)
} }
if out, err := m.Run(ctx, "", "testparm", "-s", "--suppress-prompt", m.SMBConf); err != nil { if out, err := m.Run(ctx, "", "testparm", "-s", "--suppress-prompt", m.SMBConf); err != nil {
_ = hostrun.Restore(ctx, m.Run, m.SMBConf) _ = hostrun.Restore(ctx, m.Run, m.SMBConf)
return fmt.Errorf("testparm rejected the config, previous file restored: %s", firstLines(out, 4)) 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 { if !active {
_, err := m.Run(ctx, "", "systemctl", "disable", "--now", "smbd", "nmbd") _, err := m.Run(ctx, "", "systemctl", "disable", "--now", "smbd", "nmbd")
if err != nil { 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 { if out, err := m.Run(ctx, "", "smbcontrol", "all", "reload-config"); err != nil {
return fmt.Errorf("reload smbd: %s", firstLines(out, 2)) 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 return nil
} }
func (m *Manager) applyNFS(c Config) error { func (m *Manager) applyNFS(c Config) error {
ctx, cancel := m.ctx() ctx, cancel := m.ctx()
defer cancel() 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) 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 !active {
if _, err := m.Run(ctx, "", "systemctl", "disable", "--now", "nfs-server"); err != nil { if _, err := m.Run(ctx, "", "systemctl", "disable", "--now", "nfs-server"); err != nil {
return fmt.Errorf("stop nfs-server: %w", err) return fmt.Errorf("stop nfs-server: %w", err)
@@ -122,7 +137,7 @@ func (m *Manager) applyNFS(c Config) error {
_, _ = m.Run(ctx, "", "exportfs", "-ra") _, _ = m.Run(ctx, "", "exportfs", "-ra")
return fmt.Errorf("exportfs rejected the exports, previous file restored: %s", firstLines(out, 4)) 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 return nil
} }
+128
View File
@@ -0,0 +1,128 @@
package shares
import (
"os"
"path/filepath"
"sort"
"strings"
)
// Other services on the host 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
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
}
+111
View File
@@ -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")
}
}
+9 -2
View File
@@ -26,8 +26,9 @@ func anyGuest(c Config) bool {
// RenderSMB produces the complete smb.conf. owner is the Unix account every // 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 // SMB session acts as (force user), so files end up with the same owner the
// containers use. // containers use. includes are drop-in files of other services appended as
func RenderSMB(c Config, owner string) string { // "include =" lines.
func RenderSMB(c Config, owner string, includes ...string) string {
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "%s\n\n[global]\n", Header) fmt.Fprintf(&b, "%s\n\n[global]\n", Header)
// Bad User turns unknown accounts (e.g. the Windows login) into guests. // 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) 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() return b.String()
} }
+1
View File
@@ -225,6 +225,7 @@ func newManager(f *fake) *Manager {
m := New(f.run) m := New(f.run)
m.LookPath = f.lookPath m.LookPath = f.lookPath
m.Owner, m.UID, m.GID = "ansible", "1000", "1000" m.Owner, m.UID, m.GID = "ansible", "1000", "1000"
m.IncludeDir, m.ExportsDir = "", "" // never read the host's drop-ins in tests
return m return m
} }
+32
View File
@@ -0,0 +1,32 @@
package web
import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"nasmon/internal/shares"
)
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)
}
}
}
+2
View File
@@ -39,6 +39,7 @@ type sharesVM struct {
NForm nfsForm NForm nfsForm
ApplyErr string // last Apply failure: config saved but not live ApplyErr string // last Apply failure: config saved but not live
Squashes []string Squashes []string
External []shares.ExternalShare // shares of other services (drop-ins), read-only
} }
func (s *Server) applyErr() string { 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.NForm.Squash = "root"
} }
} }
vm.External = s.shm.External()
if u, err := s.shm.ListUsers(); err == nil { if u, err := s.shm.ListUsers(); err == nil {
vm.Users = u vm.Users = u
} else { } else {
+1
View File
@@ -59,6 +59,7 @@ func withShareRunner(srv *Server, f *shareRunner) {
srv.shm = shares.New(f.run) srv.shm = shares.New(f.run)
srv.shm.LookPath = func(string) (string, error) { return "/usr/sbin/x", nil } 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.Owner, srv.shm.UID, srv.shm.GID = "ansible", "1000", "1000"
srv.shm.IncludeDir, srv.shm.ExportsDir = "", ""
} }
func TestSharesSMBFlow(t *testing.T) { func TestSharesSMBFlow(t *testing.T) {
+21
View File
@@ -140,5 +140,26 @@
</details> </details>
<p class="muted small">Linux: <span class="mono">sudo mount -t nfs {{.Host}}:/nas_pool_1_8_gb/documents /mnt/nas</span>; list exports with <span class="mono">showmount -e {{.Host}}</span>.</p> <p class="muted small">Linux: <span class="mono">sudo mount -t nfs {{.Host}}:/nas_pool_1_8_gb/documents /mnt/nas</span>; list exports with <span class="mono">showmount -e {{.Host}}</span>.</p>
</section> </section>
{{if .External}}
<section class="card" id="external">
<h2>Shares of other services <span class="muted small">read-only</span></h2>
<p class="muted small">Defined by other software on this host in <span class="mono">/etc/samba/smb.conf.d/*.conf</span>
(included into smb.conf) and <span class="mono">/etc/exports.d/*.exports</span>. Manage them in that software;
nasmon keeps smbd / nfs-server running while any of them exist.</p>
<div class="tablewrap"><table>
<thead><tr><th>Type</th><th>Source</th><th>Name</th><th>Path</th><th>Clients</th></tr></thead>
<tbody>
{{range .External}}<tr>
<td><span class="badge">{{if eq .Kind "smb"}}SMB{{else}}NFS{{end}}</span></td>
<td class="mono">{{.Source}}</td>
<td class="mono">{{if .Name}}\\{{$.Data.Host}}\{{.Name}}{{else}}—{{end}}</td>
<td class="mono small">{{.Path}}</td>
<td class="mono small">{{.Clients}}</td>
</tr>{{end}}
</tbody>
</table></div>
</section>
{{end}}
{{end}} {{end}}
{{end}} {{end}}
+14
View File
@@ -47,3 +47,17 @@ 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. 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 Resolved alerts older than 7 days are hidden by default, older than 90 days
deleted. 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/<name>.conf. nasmon appends
"include = <file>" for every *.conf there to the smb.conf it generates.
* NFS: write /etc/exports.d/<name>.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.