Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42cfb43e2e | ||
|
|
30dbb00dd1 | ||
|
|
661a862f8d |
+12
-48
@@ -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"
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
@@ -43,14 +47,6 @@ One-time tool setup:
|
|||||||
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest
|
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Releases
|
|
||||||
|
|
||||||
CI (`.gitea/workflows/build.yml`) releases every push to `main`: it tags the
|
|
||||||
commit with the next patch version (v0.1.3 → v0.1.4), builds deb + rpm with
|
|
||||||
that version and publishes a Gitea release with both packages and the commit
|
|
||||||
subjects as notes. To bump minor/major, push a tag yourself (`git tag v0.2.0 &&
|
|
||||||
git push origin v0.2.0`); later pushes to main continue from it.
|
|
||||||
|
|
||||||
## Packaging
|
## Packaging
|
||||||
|
|
||||||
`packaging/nfpm.yaml` builds both formats from one spec:
|
`packaging/nfpm.yaml` builds both formats from one spec:
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
// 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user