Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eab036de7 | ||
|
|
4a03d58193 | ||
|
|
0cdee29359 | ||
|
|
b965d2c9db | ||
|
|
6362f1b9f7 | ||
|
|
b331bc736d |
+48
-12
@@ -1,5 +1,8 @@
|
|||||||
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: ["**"]
|
||||||
@@ -14,6 +17,8 @@ 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:
|
||||||
@@ -24,6 +29,27 @@ 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
|
||||||
|
|
||||||
@@ -42,31 +68,41 @@ jobs:
|
|||||||
dist/*.rpm
|
dist/*.rpm
|
||||||
|
|
||||||
- name: publish release
|
- name: publish release
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: steps.ver.outputs.tag != ''
|
||||||
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
|
||||||
tag="${GITHUB_REF#refs/tags/}"
|
auth="Authorization: token $GITEA_TOKEN"
|
||||||
|
|
||||||
release_id=$(curl -sf -X POST "$API/releases" \
|
# release notes: commit subjects since the previous release tag
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
prev=$(git tag --merged HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -vx "$TAG" | sort -V | tail -1 || true)
|
||||||
-H "Content-Type: application/json" \
|
git log --no-merges --pretty='- %s' ${prev:+"$prev"..}HEAD > notes.md
|
||||||
-d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
|
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()}))')
|
||||||
|
|
||||||
|
# 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" \
|
release_id=$(curl -sf "$API/releases/tags/$TAG" -H "$auth" \
|
||||||
-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
|
||||||
curl -sf -X POST "$API/releases/$release_id/assets?name=$(basename "$f")" \
|
name=$(basename "$f")
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
if grep -q "\"name\":\"$name\"" <<<"$existing"; then
|
||||||
-F "attachment=@${f}"
|
echo "$name already attached"
|
||||||
|
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"
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ 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:
|
||||||
|
|||||||
+11
-2
@@ -35,6 +35,8 @@ type Pool struct {
|
|||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Action string `json:"action,omitempty"`
|
Action string `json:"action,omitempty"`
|
||||||
Vdevs []Vdev `json:"vdevs"`
|
Vdevs []Vdev `json:"vdevs"`
|
||||||
|
// DedupRatio is the pool-wide dedup ratio (1.00 = nothing deduplicated).
|
||||||
|
DedupRatio float64 `json:"dedup_ratio"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Dataset struct {
|
type Dataset struct {
|
||||||
@@ -43,11 +45,12 @@ type Dataset struct {
|
|||||||
Avail uint64 `json:"avail"`
|
Avail uint64 `json:"avail"`
|
||||||
Refer uint64 `json:"refer"`
|
Refer uint64 `json:"refer"`
|
||||||
Mountpoint string `json:"mountpoint"`
|
Mountpoint string `json:"mountpoint"`
|
||||||
|
Dedup string `json:"dedup"` // dedup property: off, on, verify, sha256, ...
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadPools combines zpool list and zpool status for every pool.
|
// ReadPools combines zpool list and zpool status for every pool.
|
||||||
func ReadPools() ([]Pool, error) {
|
func ReadPools() ([]Pool, error) {
|
||||||
out, err := run(20*time.Second, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,cap,health,frag")
|
out, err := run(20*time.Second, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,cap,health,frag,dedup")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -63,7 +66,7 @@ func ReadPools() ([]Pool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ReadDatasets() ([]Dataset, error) {
|
func ReadDatasets() ([]Dataset, error) {
|
||||||
out, err := run(20*time.Second, "zfs", "list", "-Hp", "-t", "filesystem", "-o", "name,used,avail,refer,mountpoint")
|
out, err := run(20*time.Second, "zfs", "list", "-Hp", "-t", "filesystem", "-o", "name,used,avail,refer,mountpoint,dedup")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -84,6 +87,9 @@ func parseZpoolList(b []byte) []Pool {
|
|||||||
p.Free, _ = strconv.ParseUint(f[3], 10, 64)
|
p.Free, _ = strconv.ParseUint(f[3], 10, 64)
|
||||||
p.CapPct = atoiPct(f[4])
|
p.CapPct = atoiPct(f[4])
|
||||||
p.FragPct = atoiPct(f[6])
|
p.FragPct = atoiPct(f[6])
|
||||||
|
if len(f) > 7 {
|
||||||
|
p.DedupRatio, _ = strconv.ParseFloat(strings.TrimSuffix(strings.TrimSpace(f[7]), "x"), 64)
|
||||||
|
}
|
||||||
if p.CapPct == 0 && p.Size > 0 {
|
if p.CapPct == 0 && p.Size > 0 {
|
||||||
p.CapPct = int(p.Alloc * 100 / p.Size)
|
p.CapPct = int(p.Alloc * 100 / p.Size)
|
||||||
}
|
}
|
||||||
@@ -220,6 +226,9 @@ func parseZfsList(b []byte) []Dataset {
|
|||||||
d.Used, _ = strconv.ParseUint(f[1], 10, 64)
|
d.Used, _ = strconv.ParseUint(f[1], 10, 64)
|
||||||
d.Avail, _ = strconv.ParseUint(f[2], 10, 64)
|
d.Avail, _ = strconv.ParseUint(f[2], 10, 64)
|
||||||
d.Refer, _ = strconv.ParseUint(f[3], 10, 64)
|
d.Refer, _ = strconv.ParseUint(f[3], 10, 64)
|
||||||
|
if len(f) > 5 {
|
||||||
|
d.Dedup = f[5]
|
||||||
|
}
|
||||||
out = append(out, d)
|
out = append(out, d)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -119,6 +119,15 @@ func TestParseZpoolList(t *testing.T) {
|
|||||||
if pools[1].CapPct != 90 || pools[1].FragPct != 0 {
|
if pools[1].CapPct != 90 || pools[1].FragPct != 0 {
|
||||||
t.Errorf("pool1 = %+v", pools[1])
|
t.Errorf("pool1 = %+v", pools[1])
|
||||||
}
|
}
|
||||||
|
if pools[0].DedupRatio != 0 {
|
||||||
|
t.Errorf("dedup ratio without the column = %v", pools[0].DedupRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
// with the dedup column; -p prints "1.35", without -p it is "1.35x"
|
||||||
|
pools = parseZpoolList([]byte("a\t1\t1\t0\t100\tONLINE\t0\t1.35\nb\t1\t1\t0\t100\tONLINE\t0\t2.00x\n"))
|
||||||
|
if len(pools) != 2 || pools[0].DedupRatio != 1.35 || pools[1].DedupRatio != 2 {
|
||||||
|
t.Errorf("dedup ratio = %+v", pools)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseZfsList(t *testing.T) {
|
func TestParseZfsList(t *testing.T) {
|
||||||
@@ -127,4 +136,8 @@ func TestParseZfsList(t *testing.T) {
|
|||||||
if len(ds) != 2 || ds[1].Name != "nas_pool_1_8_gb/appdata" || ds[1].Avail != 200 {
|
if len(ds) != 2 || ds[1].Name != "nas_pool_1_8_gb/appdata" || ds[1].Avail != 200 {
|
||||||
t.Errorf("datasets = %+v", ds)
|
t.Errorf("datasets = %+v", ds)
|
||||||
}
|
}
|
||||||
|
ds = parseZfsList([]byte("tank\t1\t2\t3\t/tank\ton\n"))
|
||||||
|
if len(ds) != 1 || ds[0].Dedup != "on" || ds[0].Mountpoint != "/tank" {
|
||||||
|
t.Errorf("dedup column: %+v", ds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type SMBConfig struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Workgroup string `json:"workgroup"`
|
Workgroup string `json:"workgroup"`
|
||||||
ServerString string `json:"server_string"`
|
ServerString string `json:"server_string"`
|
||||||
|
Legacy bool `json:"legacy"` // allow SMB1 + NTLMv1 (IPMI/BMC virtual media, old devices)
|
||||||
Shares []SMBShare `json:"shares"`
|
Shares []SMBShare `json:"shares"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package shares
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pseudoFS are filesystem types that never hold user data.
|
||||||
|
var pseudoFS = map[string]bool{
|
||||||
|
"proc": true, "sysfs": true, "tmpfs": true, "devtmpfs": true, "devpts": true, "cgroup": true, "cgroup2": true,
|
||||||
|
"securityfs": true, "pstore": true, "bpf": true, "debugfs": true, "tracefs": true, "configfs": true,
|
||||||
|
"fusectl": true, "mqueue": true, "hugetlbfs": true, "autofs": true, "binfmt_misc": true, "nsfs": true,
|
||||||
|
"overlay": true, "squashfs": true, "efivarfs": true, "rpc_pipefs": true, "nfsd": true, "ramfs": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMounts extracts shareable mountpoints from /proc/self/mounts content:
|
||||||
|
// real filesystems outside system directories, sorted and de-duplicated.
|
||||||
|
func ParseMounts(data string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, line := range strings.Split(data, "\n") {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
if len(f) < 3 || pseudoFS[f[2]] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mp := unescapeMount(f[1])
|
||||||
|
if !strings.HasPrefix(mp, "/") || ValidateNotSystem(mp) != nil || seen[mp] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[mp] = true
|
||||||
|
out = append(out, mp)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// unescapeMount decodes the octal escapes (\040 etc.) the kernel uses in mounts.
|
||||||
|
func unescapeMount(s string) string {
|
||||||
|
if !strings.Contains(s, `\`) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '\\' && i+4 <= len(s) {
|
||||||
|
if n, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
|
||||||
|
b.WriteByte(byte(n))
|
||||||
|
i += 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -15,20 +15,42 @@ func yesno(b bool) string {
|
|||||||
return "no"
|
return "no"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func anyGuest(c Config) bool {
|
||||||
|
for _, s := range c.SMB.Shares {
|
||||||
|
if s.Guest {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
func RenderSMB(c Config, owner string) string {
|
func RenderSMB(c Config, owner 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.
|
||||||
|
// Without guest shares that only yields a guest session Windows refuses
|
||||||
|
// with a misleading "network path not found" instead of a password prompt.
|
||||||
|
mapGuest := "Never"
|
||||||
|
if anyGuest(c) {
|
||||||
|
mapGuest = "Bad User"
|
||||||
|
}
|
||||||
|
minProto := "server min protocol = SMB2"
|
||||||
|
if c.SMB.Legacy {
|
||||||
|
// old embedded clients (e.g. Supermicro BMC virtual media) only offer
|
||||||
|
// SMB1 "NT LM 0.12" and authenticate with NTLMv1
|
||||||
|
minProto = "server min protocol = NT1\n ntlm auth = ntlmv1-permitted"
|
||||||
|
}
|
||||||
g := []string{
|
g := []string{
|
||||||
"workgroup = " + c.SMB.Workgroup,
|
"workgroup = " + c.SMB.Workgroup,
|
||||||
"server string = " + c.SMB.ServerString,
|
"server string = " + c.SMB.ServerString,
|
||||||
"server role = standalone server",
|
"server role = standalone server",
|
||||||
"security = user",
|
"security = user",
|
||||||
"map to guest = Bad User",
|
"map to guest = " + mapGuest,
|
||||||
"guest account = nobody",
|
"guest account = nobody",
|
||||||
"server min protocol = SMB2",
|
minProto,
|
||||||
"unix charset = UTF-8",
|
"unix charset = UTF-8",
|
||||||
"logging = file",
|
"logging = file",
|
||||||
"log file = /var/log/samba/log.%m",
|
"log file = /var/log/samba/log.%m",
|
||||||
@@ -102,7 +124,7 @@ func RenderExports(c Config, uid, gid string) string {
|
|||||||
default:
|
default:
|
||||||
opts = append(opts, "root_squash", "anonuid="+uid, "anongid="+gid)
|
opts = append(opts, "root_squash", "anonuid="+uid, "anongid="+gid)
|
||||||
}
|
}
|
||||||
// crossmnt: child ZFS datasets are separate mounts and must be reachable
|
// crossmnt: child mounts (ZFS datasets or other filesystems) must be reachable
|
||||||
opts = append(opts, "crossmnt", "no_subtree_check")
|
opts = append(opts, "crossmnt", "no_subtree_check")
|
||||||
var parts []string
|
var parts []string
|
||||||
for _, cl := range e.Clients {
|
for _, cl := range e.Clients {
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ func TestRenderSMB(t *testing.T) {
|
|||||||
if strings.Count(out, "\n veto files = ") != 1 {
|
if strings.Count(out, "\n veto files = ") != 1 {
|
||||||
t.Errorf("veto files only where requested")
|
t.Errorf("veto files only where requested")
|
||||||
}
|
}
|
||||||
|
// without guest shares unknown users are rejected, so Windows asks for a password
|
||||||
|
c := sample()
|
||||||
|
c.SMB.Shares = c.SMB.Shares[1:]
|
||||||
|
if out := RenderSMB(c, "ansible"); !strings.Contains(out, "map to guest = Never") || strings.Contains(out, "Bad User") {
|
||||||
|
t.Errorf("user-only config must not map to guest:\n%s", out)
|
||||||
|
}
|
||||||
|
if out := RenderSMB(c, "ansible"); strings.Contains(out, "NT1") || strings.Contains(out, "ntlmv1") {
|
||||||
|
t.Errorf("SMB1 enabled without the legacy flag")
|
||||||
|
}
|
||||||
|
c.SMB.Legacy = true
|
||||||
|
if out := RenderSMB(c, "ansible"); !strings.Contains(out, "\n server min protocol = NT1\n ntlm auth = ntlmv1-permitted\n") || strings.Contains(out, "SMB2") {
|
||||||
|
t.Errorf("legacy mode:\n%s", out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderExports(t *testing.T) {
|
func TestRenderExports(t *testing.T) {
|
||||||
@@ -70,12 +83,13 @@ func TestValidation(t *testing.T) {
|
|||||||
t.Errorf("%q accepted", n)
|
t.Errorf("%q accepted", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, p := range []string{"/nas/x", "/tmp_pool"} {
|
for _, p := range []string{"/nas/x", "/tmp_pool", "/mnt/usb", "/srv/media", "/home/alice/docs", "/media/disk", "/variable", "/etcetera"} {
|
||||||
if err := ValidatePath(p); err != nil {
|
if err := ValidatePath(p); err != nil {
|
||||||
t.Errorf("%q rejected: %v", p, err)
|
t.Errorf("%q rejected: %v", p, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, p := range []string{"", "/", "nas/x", "/nas/../etc", "/nas/", "/nas/x\"y", "/nas/x\n"} {
|
for _, p := range []string{"", "/", "nas/x", "/nas/../etc", "/nas/", "/nas/x\"y", "/nas/x\n",
|
||||||
|
"/etc", "/etc/samba", "/var/lib/docker", "/usr", "/proc/1", "/root", "/boot/efi", "/run/user/1000"} {
|
||||||
if ValidatePath(p) == nil {
|
if ValidatePath(p) == nil {
|
||||||
t.Errorf("%q accepted", p)
|
t.Errorf("%q accepted", p)
|
||||||
}
|
}
|
||||||
@@ -336,3 +350,27 @@ func TestUsers(t *testing.T) {
|
|||||||
t.Errorf("owner account deleted")
|
t.Errorf("owner account deleted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseMounts(t *testing.T) {
|
||||||
|
in := `proc /proc proc rw,nosuid 0 0
|
||||||
|
tmpfs /run tmpfs rw 0 0
|
||||||
|
/dev/sda2 / ext4 rw 0 0
|
||||||
|
/dev/sda1 /boot/efi vfat rw 0 0
|
||||||
|
tank /tank zfs rw,xattr 0 0
|
||||||
|
tank /tank zfs rw,xattr 0 0
|
||||||
|
/dev/sdc1 /mnt/usb\040disk ext4 rw 0 0
|
||||||
|
overlay /srv/docker/merged overlay rw 0 0
|
||||||
|
`
|
||||||
|
got := strings.Join(ParseMounts(in), "|")
|
||||||
|
if got != "/mnt/usb disk|/tank" {
|
||||||
|
t.Errorf("ParseMounts = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigRefusesSystemPath(t *testing.T) {
|
||||||
|
c := Defaults()
|
||||||
|
c.SMB.Shares = []SMBShare{{Name: "etc", Path: "/etc", Guest: true}}
|
||||||
|
if c.Validate() == nil {
|
||||||
|
t.Errorf("share on /etc accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,8 +54,23 @@ func ValidateComment(s string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidatePath accepts an absolute, clean path without control characters.
|
// systemRoots are OS trees that must never be shared.
|
||||||
// Whether it lies under a pool is checked by the caller (needs live state).
|
var systemRoots = []string{"/etc", "/proc", "/sys", "/dev", "/boot", "/run", "/usr", "/bin", "/sbin",
|
||||||
|
"/lib", "/lib32", "/lib64", "/libx32", "/var", "/root", "/snap", "/lost+found"}
|
||||||
|
|
||||||
|
// ValidateNotSystem refuses the root filesystem and system directories.
|
||||||
|
func ValidateNotSystem(p string) error {
|
||||||
|
if p == "/" {
|
||||||
|
return errors.New("refusing to share the root filesystem")
|
||||||
|
}
|
||||||
|
if UnderRoots(p, systemRoots) {
|
||||||
|
return fmt.Errorf("%s is a system directory and cannot be shared", p)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePath accepts an absolute, clean, non-system path without control
|
||||||
|
// characters. Existence and symlinks are checked by the caller.
|
||||||
func ValidatePath(p string) error {
|
func ValidatePath(p string) error {
|
||||||
if p == "" {
|
if p == "" {
|
||||||
return errors.New("path is required")
|
return errors.New("path is required")
|
||||||
@@ -63,10 +78,7 @@ func ValidatePath(p string) error {
|
|||||||
if !strings.HasPrefix(p, "/") || path.Clean(p) != p || strings.ContainsAny(p, "\"\\\r\n\t") {
|
if !strings.HasPrefix(p, "/") || path.Clean(p) != p || strings.ContainsAny(p, "\"\\\r\n\t") {
|
||||||
return errors.New("path must be absolute and normalised (no .., trailing / or quotes)")
|
return errors.New("path must be absolute and normalised (no .., trailing / or quotes)")
|
||||||
}
|
}
|
||||||
if p == "/" {
|
return ValidateNotSystem(p)
|
||||||
return errors.New("refusing to share the root filesystem")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnderRoots reports whether p equals or lies below one of the roots.
|
// UnderRoots reports whether p equals or lies below one of the roots.
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ type zfsVM struct {
|
|||||||
S sched.Status
|
S sched.Status
|
||||||
Th config.Thresholds
|
Th config.Thresholds
|
||||||
Datasets []datasetRow
|
Datasets []datasetRow
|
||||||
Form createForm // last submitted values (re-rendered after an error)
|
// PoolDedup maps a pool to the dedup property of its root dataset.
|
||||||
|
PoolDedup map[string]string
|
||||||
|
Form createForm // last submitted values (re-rendered after an error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type datasetRow struct {
|
type datasetRow struct {
|
||||||
@@ -134,15 +136,18 @@ type datasetRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type createForm struct {
|
type createForm struct {
|
||||||
Pool, Name, Owner, Compression, Quota string
|
Pool, Name, Owner, Compression, Dedup, Quota string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) zfsView(st sched.Status, form createForm) zfsVM {
|
func (s *Server) zfsView(st sched.Status, form createForm) zfsVM {
|
||||||
vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form}
|
vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form, PoolDedup: map[string]string{}}
|
||||||
if vm.Form.Owner == "" {
|
if vm.Form.Owner == "" {
|
||||||
vm.Form.Owner = defaultOwner
|
vm.Form.Owner = defaultOwner
|
||||||
}
|
}
|
||||||
for _, d := range st.Datasets {
|
for _, d := range st.Datasets {
|
||||||
|
if !strings.Contains(d.Name, "/") {
|
||||||
|
vm.PoolDedup[d.Name] = d.Dedup
|
||||||
|
}
|
||||||
row := datasetRow{Dataset: d, DomID: strings.NewReplacer("/", "-", ".", "_", ":", "_").Replace(d.Name)}
|
row := datasetRow{Dataset: d, DomID: strings.NewReplacer("/", "-", ".", "_", ":", "_").Replace(d.Name)}
|
||||||
row.CanDestroy = strings.Contains(d.Name, "/") && d.Refer < zfsadm.EmptyRefer
|
row.CanDestroy = strings.Contains(d.Name, "/") && d.Refer < zfsadm.EmptyRefer
|
||||||
for _, o := range st.Datasets {
|
for _, o := range st.Datasets {
|
||||||
@@ -170,6 +175,7 @@ func (s *Server) zfsCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
Name: strings.Trim(strings.TrimSpace(r.FormValue("name")), "/"),
|
Name: strings.Trim(strings.TrimSpace(r.FormValue("name")), "/"),
|
||||||
Owner: strings.TrimSpace(r.FormValue("owner")),
|
Owner: strings.TrimSpace(r.FormValue("owner")),
|
||||||
Compression: r.FormValue("compression"),
|
Compression: r.FormValue("compression"),
|
||||||
|
Dedup: r.FormValue("dedup"),
|
||||||
Quota: strings.ToUpper(strings.TrimSpace(r.FormValue("quota"))),
|
Quota: strings.ToUpper(strings.TrimSpace(r.FormValue("quota"))),
|
||||||
}
|
}
|
||||||
st := s.sch.Status()
|
st := s.sch.Status()
|
||||||
@@ -191,20 +197,20 @@ func (s *Server) zfsCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, err := range []error{zfsadm.ValidateOwner(f.Owner), zfsadm.ValidateCompression(f.Compression), zfsadm.ValidateSize(f.Quota)} {
|
for _, err := range []error{zfsadm.ValidateOwner(f.Owner), zfsadm.ValidateCompression(f.Compression), zfsadm.ValidateDedup(f.Dedup), zfsadm.ValidateSize(f.Quota)} {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(err.Error())
|
fail(err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Quota: f.Quota, Owner: f.Owner})
|
err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Dedup: f.Dedup, Quota: f.Quota, Owner: f.Owner})
|
||||||
s.sch.RefreshStorage()
|
s.sch.RefreshStorage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("zfs create %s: %v", full, err)
|
log.Printf("zfs create %s: %v", full, err)
|
||||||
fail(err.Error())
|
fail(err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("dataset %s created (owner %s, compression %q, quota %q)", full, f.Owner, f.Compression, f.Quota)
|
log.Printf("dataset %s created (owner %s, compression %q, dedup %q, quota %q)", full, f.Owner, f.Compression, f.Dedup, f.Quota)
|
||||||
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+full+" created")+"#"+url.PathEscape(f.Pool), http.StatusSeeOther)
|
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+full+" created")+"#"+url.PathEscape(f.Pool), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +249,37 @@ func (s *Server) zfsDestroy(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+name+" destroyed"), http.StatusSeeOther)
|
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+name+" destroyed"), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zfsDedup turns dedup on/off on a pool's root dataset (inherited by children).
|
||||||
|
func (s *Server) zfsDedup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pool := strings.TrimSpace(r.FormValue("pool"))
|
||||||
|
value := r.FormValue("dedup")
|
||||||
|
st := s.sch.Status()
|
||||||
|
fail := func(msg string) {
|
||||||
|
s.render(w, r, "zfs", pageData{Title: "ZFS", Data: s.zfsView(st, createForm{}), Alerts: len(st.Alerts), Error: "Dedup not changed: " + msg})
|
||||||
|
}
|
||||||
|
if !hasPool(st, pool) {
|
||||||
|
fail("unknown pool " + pool)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if value != "on" && value != "off" {
|
||||||
|
fail("dedup must be on or off")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := s.admin.SetDedup(pool, value)
|
||||||
|
s.sch.RefreshStorage()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("zfs set dedup=%s %s: %v", value, pool, err)
|
||||||
|
fail(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("dedup=%s set on pool %s", value, pool)
|
||||||
|
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dedup "+value+" for "+pool)+"#"+url.PathEscape(pool), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
func hasPool(st sched.Status, name string) bool {
|
func hasPool(st sched.Status, name string) bool {
|
||||||
for _, p := range st.Pools {
|
for _, p := range st.Pools {
|
||||||
if p.Name == name {
|
if p.Name == name {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -38,7 +39,10 @@ type Server struct {
|
|||||||
pages map[string]*template.Template
|
pages map[string]*template.Template
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
|
|
||||||
dirExists func(string) bool // share path check; replaced in tests
|
// share path checks; replaced in tests
|
||||||
|
dirExists func(string) bool
|
||||||
|
realPath func(string) (string, error)
|
||||||
|
readMounts func() string
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
shareErr string // last failed Apply of the share config
|
shareErr string // last failed Apply of the share config
|
||||||
@@ -47,7 +51,8 @@ type Server struct {
|
|||||||
func New(cfg *config.Store, st *store.Store, ring *history.Ring, sch *sched.Scheduler, sh *shares.Store) *Server {
|
func New(cfg *config.Store, st *store.Store, ring *history.Ring, sch *sched.Scheduler, sh *shares.Store) *Server {
|
||||||
run := hostrun.Auto()
|
run := hostrun.Auto()
|
||||||
s := &Server{cfg: cfg, st: st, ring: ring, sch: sch, send: alert.SendMail,
|
s := &Server{cfg: cfg, st: st, ring: ring, sch: sch, send: alert.SendMail,
|
||||||
admin: zfsadm.New(zfsadm.NoInput(run)), shares: sh, shm: shares.New(run), dirExists: statDir, mux: http.NewServeMux()}
|
admin: zfsadm.New(zfsadm.NoInput(run)), shares: sh, shm: shares.New(run),
|
||||||
|
dirExists: statDir, realPath: filepath.EvalSymlinks, readMounts: procMounts, mux: http.NewServeMux()}
|
||||||
s.pages = map[string]*template.Template{}
|
s.pages = map[string]*template.Template{}
|
||||||
layout := template.Must(template.New("layout").Funcs(funcs).ParseFS(assets, "templates/layout.html"))
|
layout := template.Must(template.New("layout").Funcs(funcs).ParseFS(assets, "templates/layout.html"))
|
||||||
for _, name := range []string{"dashboard", "disk", "zfs", "alerts", "settings", "shares"} {
|
for _, name := range []string{"dashboard", "disk", "zfs", "alerts", "settings", "shares"} {
|
||||||
@@ -68,6 +73,7 @@ func New(cfg *config.Store, st *store.Store, ring *history.Ring, sch *sched.Sche
|
|||||||
s.mux.HandleFunc("GET /zfs", s.zfs)
|
s.mux.HandleFunc("GET /zfs", s.zfs)
|
||||||
s.mux.HandleFunc("POST /zfs/create", s.zfsCreate)
|
s.mux.HandleFunc("POST /zfs/create", s.zfsCreate)
|
||||||
s.mux.HandleFunc("POST /zfs/destroy", s.zfsDestroy)
|
s.mux.HandleFunc("POST /zfs/destroy", s.zfsDestroy)
|
||||||
|
s.mux.HandleFunc("POST /zfs/dedup", s.zfsDedup)
|
||||||
s.mux.HandleFunc("GET /alerts", s.alerts)
|
s.mux.HandleFunc("GET /alerts", s.alerts)
|
||||||
s.mux.HandleFunc("POST /alerts/{id}/{action}", s.alertAction)
|
s.mux.HandleFunc("POST /alerts/{id}/{action}", s.alertAction)
|
||||||
s.mux.HandleFunc("GET /shares", s.sharesPage)
|
s.mux.HandleFunc("GET /shares", s.sharesPage)
|
||||||
|
|||||||
+38
-19
@@ -53,21 +53,39 @@ func (s *Server) setApplyErr(e string) {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// mountpoints lists dataset mountpoints — the only places shares may point to.
|
// pathSuggestions lists ZFS dataset mountpoints plus other mounted data
|
||||||
func mountpoints(st sched.Status) []string {
|
// filesystems (USB disks, /mnt, /srv…) for the path input. Any non-system
|
||||||
|
// folder may be shared; these are only hints.
|
||||||
|
func (s *Server) pathSuggestions(st sched.Status) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
var out []string
|
var out []string
|
||||||
for _, d := range st.Datasets {
|
add := func(p string) {
|
||||||
if strings.HasPrefix(d.Mountpoint, "/") {
|
if strings.HasPrefix(p, "/") && !seen[p] && shares.ValidateNotSystem(p) == nil {
|
||||||
out = append(out, d.Mountpoint)
|
seen[p] = true
|
||||||
|
out = append(out, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, d := range st.Datasets {
|
||||||
|
add(d.Mountpoint)
|
||||||
|
}
|
||||||
|
for _, p := range shares.ParseMounts(s.readMounts()) {
|
||||||
|
add(p)
|
||||||
|
}
|
||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func procMounts() string {
|
||||||
|
b, err := os.ReadFile("/proc/self/mounts")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) sharesView(st sched.Status, form smbForm, nform nfsForm) sharesVM {
|
func (s *Server) sharesView(st sched.Status, form smbForm, nform nfsForm) sharesVM {
|
||||||
c := s.shares.Get()
|
c := s.shares.Get()
|
||||||
vm := sharesVM{C: c, Svc: st.Services, Mountpoints: mountpoints(st), Owner: s.shm.Owner, Host: st.Host,
|
vm := sharesVM{C: c, Svc: st.Services, Mountpoints: s.pathSuggestions(st), Owner: s.shm.Owner, Host: st.Host,
|
||||||
Form: form, NForm: nform, ApplyErr: s.applyErr(), Squashes: []string{"root", "all", "none"}}
|
Form: form, NForm: nform, ApplyErr: s.applyErr(), Squashes: []string{"root", "all", "none"}}
|
||||||
if vm.Form.Name == "" && vm.Form.Orig == "" {
|
if vm.Form.Name == "" && vm.Form.Orig == "" {
|
||||||
vm.Form.New, vm.Form.Guest, vm.Form.Browseable, vm.Form.HideSys = true, true, true, true
|
vm.Form.New, vm.Form.Guest, vm.Form.Browseable, vm.Form.HideSys = true, true, true, true
|
||||||
@@ -147,17 +165,22 @@ func (s *Server) sharesDone(w http.ResponseWriter, r *http.Request, msg, anchor
|
|||||||
http.Redirect(w, r, "/shares?msg="+url.QueryEscape(msg)+anchor, http.StatusSeeOther)
|
http.Redirect(w, r, "/shares?msg="+url.QueryEscape(msg)+anchor, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pathOK checks that a share path exists and lies inside a ZFS dataset.
|
// pathOK checks that a share path is an existing, non-system directory, also
|
||||||
func (s *Server) pathOK(st sched.Status, p string) error {
|
// after resolving symlinks (so /tank/link -> /etc is refused).
|
||||||
|
func (s *Server) pathOK(p string) error {
|
||||||
if err := shares.ValidatePath(p); err != nil {
|
if err := shares.ValidatePath(p); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !shares.UnderRoots(p, mountpoints(st)) {
|
|
||||||
return errPathOutside
|
|
||||||
}
|
|
||||||
if !s.dirExists(p) {
|
if !s.dirExists(p) {
|
||||||
return errPathMissing
|
return errPathMissing
|
||||||
}
|
}
|
||||||
|
rp, err := s.realPath(p)
|
||||||
|
if err != nil {
|
||||||
|
return errPathMissing
|
||||||
|
}
|
||||||
|
if rp != p && shares.ValidateNotSystem(rp) != nil {
|
||||||
|
return strErr("path resolves to " + rp + ", a system directory")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,10 +193,7 @@ type strErr string
|
|||||||
|
|
||||||
func (e strErr) Error() string { return string(e) }
|
func (e strErr) Error() string { return string(e) }
|
||||||
|
|
||||||
const (
|
const errPathMissing = strErr("path does not exist or is not a directory")
|
||||||
errPathOutside = strErr("path must be inside a ZFS dataset (see the list of mountpoints)")
|
|
||||||
errPathMissing = strErr("path does not exist or is not a directory")
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) smbSettings(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) smbSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
@@ -184,6 +204,7 @@ func (s *Server) smbSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
c.SMB.Enabled = r.FormValue("enabled") != ""
|
c.SMB.Enabled = r.FormValue("enabled") != ""
|
||||||
c.SMB.Workgroup = strings.ToUpper(strings.TrimSpace(r.FormValue("workgroup")))
|
c.SMB.Workgroup = strings.ToUpper(strings.TrimSpace(r.FormValue("workgroup")))
|
||||||
c.SMB.ServerString = strings.TrimSpace(r.FormValue("server_string"))
|
c.SMB.ServerString = strings.TrimSpace(r.FormValue("server_string"))
|
||||||
|
c.SMB.Legacy = r.FormValue("legacy") != ""
|
||||||
if err := shares.ValidateComment(c.SMB.ServerString); err != nil {
|
if err := shares.ValidateComment(c.SMB.ServerString); err != nil {
|
||||||
s.sharesFail(w, r, "Not saved: server description: "+err.Error(), smbForm{}, nfsForm{})
|
s.sharesFail(w, r, "Not saved: server description: "+err.Error(), smbForm{}, nfsForm{})
|
||||||
return
|
return
|
||||||
@@ -210,7 +231,6 @@ func (s *Server) smbSave(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(f.Path) > 1 {
|
if len(f.Path) > 1 {
|
||||||
f.Path = strings.TrimRight(f.Path, "/")
|
f.Path = strings.TrimRight(f.Path, "/")
|
||||||
}
|
}
|
||||||
st := s.sch.Status()
|
|
||||||
sh := shares.SMBShare{Name: f.Name, Path: f.Path, Comment: f.Comment, ReadOnly: f.ReadOnly, Guest: f.Guest,
|
sh := shares.SMBShare{Name: f.Name, Path: f.Path, Comment: f.Comment, ReadOnly: f.ReadOnly, Guest: f.Guest,
|
||||||
Users: f.Users, Browseable: f.Browseable, HideSysFiles: f.HideSys}
|
Users: f.Users, Browseable: f.Browseable, HideSysFiles: f.HideSys}
|
||||||
if sh.Guest {
|
if sh.Guest {
|
||||||
@@ -220,7 +240,7 @@ func (s *Server) smbSave(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{})
|
s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.pathOK(st, sh.Path); err != nil {
|
if err := s.pathOK(sh.Path); err != nil {
|
||||||
s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{})
|
s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -276,13 +296,12 @@ func (s *Server) nfsSave(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(f.Path) > 1 {
|
if len(f.Path) > 1 {
|
||||||
f.Path = strings.TrimRight(f.Path, "/")
|
f.Path = strings.TrimRight(f.Path, "/")
|
||||||
}
|
}
|
||||||
st := s.sch.Status()
|
|
||||||
e := shares.NFSExport{Path: f.Path, Clients: shares.SplitList(f.Clients), ReadOnly: f.ReadOnly, Squash: f.Squash, Async: f.Async}
|
e := shares.NFSExport{Path: f.Path, Clients: shares.SplitList(f.Clients), ReadOnly: f.ReadOnly, Squash: f.Squash, Async: f.Async}
|
||||||
if err := e.Validate(); err != nil {
|
if err := e.Validate(); err != nil {
|
||||||
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f)
|
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.pathOK(st, e.Path); err != nil {
|
if err := s.pathOK(e.Path); err != nil {
|
||||||
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f)
|
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func TestSharesSMBFlow(t *testing.T) {
|
|||||||
|
|
||||||
// page lists users and mountpoints, prefills from ?path=
|
// page lists users and mountpoints, prefills from ?path=
|
||||||
body := get(srv, "/shares?path=/tank/data").Body.String()
|
body := get(srv, "/shares?path=/tank/data").Body.String()
|
||||||
for _, want := range []string{`value="/tank/data"`, `value="data"`, `value="alice"`, "No SMB shares yet"} {
|
for _, want := range []string{`value="/tank/data"`, `value="data"`, `value="alice"`, "No SMB shares yet", `<option value="/mnt/usb">`} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("shares page missing %q", want)
|
t.Errorf("shares page missing %q", want)
|
||||||
}
|
}
|
||||||
@@ -96,10 +96,14 @@ func TestSharesSMBFlow(t *testing.T) {
|
|||||||
t.Errorf("share not listed")
|
t.Errorf("share not listed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// refused: duplicate name, path outside pools, non-guest without users, reserved name, unclean path
|
// refused: duplicate name, system dirs, symlink into a system dir, missing dir,
|
||||||
|
// non-guest without users, reserved name, unclean path
|
||||||
for _, form := range []url.Values{
|
for _, form := range []url.Values{
|
||||||
{"name": {"data"}, "path": {"/tank/data"}, "guest": {"1"}},
|
{"name": {"data"}, "path": {"/tank/data"}, "guest": {"1"}},
|
||||||
{"name": {"etc"}, "path": {"/etc"}, "guest": {"1"}},
|
{"name": {"etc"}, "path": {"/etc"}, "guest": {"1"}},
|
||||||
|
{"name": {"logs"}, "path": {"/var/log"}, "guest": {"1"}},
|
||||||
|
{"name": {"evil"}, "path": {"/srv/evil"}, "guest": {"1"}},
|
||||||
|
{"name": {"nowhere"}, "path": {"/opt/nowhere"}, "guest": {"1"}},
|
||||||
{"name": {"priv"}, "path": {"/tank/data"}},
|
{"name": {"priv"}, "path": {"/tank/data"}},
|
||||||
{"name": {"global"}, "path": {"/tank/data"}, "guest": {"1"}},
|
{"name": {"global"}, "path": {"/tank/data"}, "guest": {"1"}},
|
||||||
{"name": {"gone"}, "path": {"/tank/nope/.."}, "guest": {"1"}},
|
{"name": {"gone"}, "path": {"/tank/nope/.."}, "guest": {"1"}},
|
||||||
@@ -125,6 +129,12 @@ func TestSharesSMBFlow(t *testing.T) {
|
|||||||
if !strings.Contains(f.written(shares.DefaultSMBConf), "valid users = alice") {
|
if !strings.Contains(f.written(shares.DefaultSMBConf), "valid users = alice") {
|
||||||
t.Errorf("smb.conf not regenerated after edit")
|
t.Errorf("smb.conf not regenerated after edit")
|
||||||
}
|
}
|
||||||
|
// a plain folder outside any ZFS dataset
|
||||||
|
rec = postForm(srv, "/shares/smb/save", url.Values{"name": {"media"}, "path": {"/srv/media"}, "guest": {"1"}, "browseable": {"1"}})
|
||||||
|
if rec.Code != http.StatusSeeOther || !strings.Contains(f.written(shares.DefaultSMBConf), "path = /srv/media") {
|
||||||
|
t.Fatalf("non-ZFS share: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
postForm(srv, "/shares/smb/delete", url.Values{"name": {"media"}})
|
||||||
// a user still referenced by a share cannot be deleted
|
// a user still referenced by a share cannot be deleted
|
||||||
rec = postForm(srv, "/shares/users/delete", url.Values{"name": {"alice"}})
|
rec = postForm(srv, "/shares/users/delete", url.Values{"name": {"alice"}})
|
||||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "still allowed on share private") || f.has("smbpasswd -x") {
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "still allowed on share private") || f.has("smbpasswd -x") {
|
||||||
@@ -192,11 +202,17 @@ func TestSharesNFSFlow(t *testing.T) {
|
|||||||
{"path": {"/tank/data2"}, "clients": {""}, "squash": {"root"}}, // no clients
|
{"path": {"/tank/data2"}, "clients": {""}, "squash": {"root"}}, // no clients
|
||||||
{"path": {"/tank/data2"}, "clients": {"10.0.0.0/33"}, "squash": {"root"}}, // bad cidr
|
{"path": {"/tank/data2"}, "clients": {"10.0.0.0/33"}, "squash": {"root"}}, // bad cidr
|
||||||
{"path": {"/tank/data2"}, "clients": {"*"}, "squash": {"weird"}}, // bad squash
|
{"path": {"/tank/data2"}, "clients": {"*"}, "squash": {"weird"}}, // bad squash
|
||||||
|
{"path": {"/etc"}, "clients": {"*"}, "squash": {"root"}}, // system dir
|
||||||
} {
|
} {
|
||||||
if rec := postForm(srv, "/shares/nfs/save", form); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not saved") {
|
if rec := postForm(srv, "/shares/nfs/save", form); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not saved") {
|
||||||
t.Errorf("form %v accepted", form)
|
t.Errorf("form %v accepted", form)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if rec := postForm(srv, "/shares/nfs/save", url.Values{"path": {"/srv/media"}, "clients": {"*"}, "squash": {"root"}}); rec.Code != http.StatusSeeOther ||
|
||||||
|
!strings.Contains(f.written(shares.DefaultExports), `"/srv/media" *(`) {
|
||||||
|
t.Fatalf("non-ZFS export: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
postForm(srv, "/shares/nfs/delete", url.Values{"path": {"/srv/media"}})
|
||||||
body := get(srv, "/shares").Body.String()
|
body := get(srv, "/shares").Body.String()
|
||||||
if !strings.Contains(body, "192.168.101.0/24, pc.lan") || !strings.Contains(body, ":/tank/data") {
|
if !strings.Contains(body, "192.168.101.0/24, pc.lan") || !strings.Contains(body, ":/tank/data") {
|
||||||
t.Errorf("export not listed")
|
t.Errorf("export not listed")
|
||||||
@@ -210,3 +226,24 @@ func TestSharesNFSFlow(t *testing.T) {
|
|||||||
t.Errorf("delete: %d %v", rec.Code, f.calls)
|
t.Errorf("delete: %d %v", rec.Code, f.calls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSharesSMBLegacySetting(t *testing.T) {
|
||||||
|
srv, _, _ := newTestServer(t)
|
||||||
|
f := &shareRunner{users: "alice:1001:\n"}
|
||||||
|
withShareRunner(srv, f)
|
||||||
|
postForm(srv, "/shares/smb/save", url.Values{"name": {"iso"}, "path": {"/tank/data"}, "users": {"alice"}, "browseable": {"1"}})
|
||||||
|
rec := postForm(srv, "/shares/smb/settings", url.Values{"enabled": {"1"}, "workgroup": {"workgroup"}, "server_string": {"NAS"}, "legacy": {"1"}})
|
||||||
|
if rec.Code != http.StatusSeeOther || !srv.shares.Get().SMB.Legacy {
|
||||||
|
t.Fatalf("legacy not saved: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if conf := f.written(shares.DefaultSMBConf); !strings.Contains(conf, "server min protocol = NT1") {
|
||||||
|
t.Errorf("smb.conf without SMB1:\n%s", conf)
|
||||||
|
}
|
||||||
|
if !strings.Contains(get(srv, "/shares").Body.String(), `name="legacy" value="1" checked`) {
|
||||||
|
t.Errorf("legacy checkbox not shown as checked")
|
||||||
|
}
|
||||||
|
postForm(srv, "/shares/smb/settings", url.Values{"enabled": {"1"}, "workgroup": {"WORKGROUP"}, "server_string": {"NAS"}})
|
||||||
|
if srv.shares.Get().SMB.Legacy || !strings.Contains(f.written(shares.DefaultSMBConf), "server min protocol = SMB2") {
|
||||||
|
t.Errorf("legacy not cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<label class="check"><input type="checkbox" name="enabled" value="1" {{if .C.SMB.Enabled}}checked{{end}}> SMB server enabled</label>
|
<label class="check"><input type="checkbox" name="enabled" value="1" {{if .C.SMB.Enabled}}checked{{end}}> SMB server enabled</label>
|
||||||
<label>Workgroup <input name="workgroup" value="{{.C.SMB.Workgroup}}"></label>
|
<label>Workgroup <input name="workgroup" value="{{.C.SMB.Workgroup}}"></label>
|
||||||
<label>Description <input name="server_string" value="{{.C.SMB.ServerString}}"></label>
|
<label>Description <input name="server_string" value="{{.C.SMB.ServerString}}"></label>
|
||||||
|
<label class="check"><input type="checkbox" name="legacy" value="1" {{if .C.SMB.Legacy}}checked{{end}}> Allow legacy clients (SMB1 + NTLMv1) — IPMI/BMC virtual media, old devices; less secure</label>
|
||||||
<div class="field"><span class="field-name"> </span><span><button type="submit" class="secondary">Save SMB settings</button></span></div>
|
<div class="field"><span class="field-name"> </span><span><button type="submit" class="secondary">Save SMB settings</button></span></div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
<input type="hidden" name="orig" value="{{.Form.Orig}}">
|
<input type="hidden" name="orig" value="{{.Form.Orig}}">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label>Share name <input name="name" required value="{{.Form.Name}}" placeholder="documents"></label>
|
<label>Share name <input name="name" required value="{{.Form.Name}}" placeholder="documents"></label>
|
||||||
<label>Path <input name="path" required list="mountpoints" value="{{.Form.Path}}" placeholder="/nas_pool_1_8_gb/documents"></label>
|
<label>Path <input name="path" required list="mountpoints" value="{{.Form.Path}}" placeholder="/nas_pool_1_8_gb/documents or /mnt/usb"></label>
|
||||||
<label>Comment <input name="comment" value="{{.Form.Comment}}"></label>
|
<label>Comment <input name="comment" value="{{.Form.Comment}}"></label>
|
||||||
<label class="check"><input type="checkbox" name="read_only" value="1" {{if .Form.ReadOnly}}checked{{end}}> Read-only</label>
|
<label class="check"><input type="checkbox" name="read_only" value="1" {{if .Form.ReadOnly}}checked{{end}}> Read-only</label>
|
||||||
<label class="check"><input type="checkbox" name="guest" value="1" {{if .Form.Guest}}checked{{end}}> Guest access (anyone on the LAN, no password)</label>
|
<label class="check"><input type="checkbox" name="guest" value="1" {{if .Form.Guest}}checked{{end}}> Guest access (anyone on the LAN, no password)</label>
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
<span class="checks">{{range .Users}}<label class="check-item"><input type="checkbox" name="users" value="{{.}}" {{if contains $.Data.Form.Users .}}checked{{end}}> {{.}}</label>{{else}}<span class="muted">no SMB users yet — add one below</span>{{end}}</span>
|
<span class="checks">{{range .Users}}<label class="check-item"><input type="checkbox" name="users" value="{{.}}" {{if contains $.Data.Form.Users .}}checked{{end}}> {{.}}</label>{{else}}<span class="muted">no SMB users yet — add one below</span>{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="small muted">Path: any folder on the NAS — a ZFS dataset, a USB disk under /mnt or /media, /srv… System directories (/etc, /usr, /var…) are refused.</p>
|
||||||
<p class="small muted">Files are read and written as <b>{{.Owner}}</b> regardless of the SMB user, so containers and shares see the same ownership.</p>
|
<p class="small muted">Files are read and written as <b>{{.Owner}}</b> regardless of the SMB user, so containers and shares see the same ownership.</p>
|
||||||
<button type="submit">{{if .Form.New}}Create share{{else}}Save share{{end}}</button>
|
<button type="submit">{{if .Form.New}}Create share{{else}}Save share{{end}}</button>
|
||||||
{{if not .Form.New}}<a href="/shares" class="small">cancel</a>{{end}}
|
{{if not .Form.New}}<a href="/shares" class="small">cancel</a>{{end}}
|
||||||
@@ -79,7 +81,8 @@
|
|||||||
<div class="field"><span class="field-name"> </span><span><button type="submit" class="secondary">Add user / set password</button></span></div>
|
<div class="field"><span class="field-name"> </span><span><button type="submit" class="secondary">Add user / set password</button></span></div>
|
||||||
</form>
|
</form>
|
||||||
<p class="muted small">Windows: <span class="mono">\\{{.Host}}\share</span> in Explorer, or map a network drive. macOS: Finder → Go → Connect to Server → <span class="mono">smb://{{.Host}}/share</span>.
|
<p class="muted small">Windows: <span class="mono">\\{{.Host}}\share</span> in Explorer, or map a network drive. macOS: Finder → Go → Connect to Server → <span class="mono">smb://{{.Host}}/share</span>.
|
||||||
Note: Windows 10/11 Pro/Enterprise refuse <i>guest</i> shares by default (error 0x80070035 «network path not found») — use an SMB user with a password, or allow guest access on the PC (gpedit: Lanman Workstation → Enable insecure guest logons).</p>
|
Note: Windows 10/11 Pro/Enterprise refuse <i>guest</i> shares by default (error 0x80070035 «network path not found») — use an SMB user with a password, or allow guest access on the PC (gpedit: Lanman Workstation → Enable insecure guest logons).
|
||||||
|
If a password share still gives 0x80070035, connect explicitly: <span class="mono">net use \\{{.Host}}\share /user:<name> *</span> (Windows keeps one login per server — run <span class="mono">net use * /delete</span> first).</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card" id="nfs">
|
<section class="card" id="nfs">
|
||||||
@@ -118,7 +121,7 @@
|
|||||||
<form method="post" action="/shares/nfs/save" class="ds-form">
|
<form method="post" action="/shares/nfs/save" class="ds-form">
|
||||||
<input type="hidden" name="orig" value="{{.NForm.Orig}}">
|
<input type="hidden" name="orig" value="{{.NForm.Orig}}">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label>Path <input name="path" required list="mountpoints" value="{{.NForm.Path}}" placeholder="/nas_pool_1_8_gb/documents"></label>
|
<label>Path <input name="path" required list="mountpoints" value="{{.NForm.Path}}" placeholder="/nas_pool_1_8_gb/documents or /mnt/usb"></label>
|
||||||
<label>Clients <input name="clients" required value="{{.NForm.Clients}}" placeholder="192.168.101.0/24, pc.lan, *"></label>
|
<label>Clients <input name="clients" required value="{{.NForm.Clients}}" placeholder="192.168.101.0/24, pc.lan, *"></label>
|
||||||
<label>Squash
|
<label>Squash
|
||||||
<select name="squash">
|
<select name="squash">
|
||||||
@@ -130,7 +133,7 @@
|
|||||||
<label class="check"><input type="checkbox" name="read_only" value="1" {{if .NForm.ReadOnly}}checked{{end}}> Read-only</label>
|
<label class="check"><input type="checkbox" name="read_only" value="1" {{if .NForm.ReadOnly}}checked{{end}}> Read-only</label>
|
||||||
<label class="check"><input type="checkbox" name="async" value="1" {{if .NForm.Async}}checked{{end}}> async writes (faster, less safe on power loss)</label>
|
<label class="check"><input type="checkbox" name="async" value="1" {{if .NForm.Async}}checked{{end}}> async writes (faster, less safe on power loss)</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="small muted">Nested datasets under the path are exported too (crossmnt). Clients: host names, IPs, networks like 192.168.101.0/24, or * for everyone.</p>
|
<p class="small muted">Filesystems mounted below the path (nested datasets, disks) are exported too (crossmnt). Clients: host names, IPs, networks like 192.168.101.0/24, or * for everyone.</p>
|
||||||
<button type="submit">{{if .NForm.New}}Create export{{else}}Save export{{end}}</button>
|
<button type="submit">{{if .NForm.New}}Create export{{else}}Save export{{end}}</button>
|
||||||
{{if not .NForm.New}}<a href="/shares" class="small">cancel</a>{{end}}
|
{{if not .NForm.New}}<a href="/shares" class="small">cancel</a>{{end}}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<div><span class="muted">fragmentation</span> {{.FragPct}}%</div>
|
<div><span class="muted">fragmentation</span> {{.FragPct}}%</div>
|
||||||
<div><span class="muted">last scrub</span> {{if .LastScrub}}{{dtp .LastScrub}} ({{agop .LastScrub}}){{else}}never{{end}}</div>
|
<div><span class="muted">last scrub</span> {{if .LastScrub}}{{dtp .LastScrub}} ({{agop .LastScrub}}){{else}}never{{end}}</div>
|
||||||
<div><span class="muted">errors</span> {{if .Errors}}{{.Errors}}{{else}}—{{end}}</div>
|
<div><span class="muted">errors</span> {{if .Errors}}{{.Errors}}{{else}}—{{end}}</div>
|
||||||
|
<div><span class="muted">dedup</span> {{with index $.Data.PoolDedup .Name}}{{.}}{{else}}—{{end}}{{if .DedupRatio}} (ratio {{printf "%.2f" .DedupRatio}}x){{end}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bar"><span class="fill {{capClass .CapPct $.Data.Th.PoolCapacityPct 95}}" style="width:{{.CapPct}}%"></span></div>
|
<div class="bar"><span class="fill {{capClass .CapPct $.Data.Th.PoolCapacityPct 95}}" style="width:{{.CapPct}}%"></span></div>
|
||||||
{{if .Scan}}<p class="small"><span class="muted">scan:</span> {{.Scan}}</p>{{end}}
|
{{if .Scan}}<p class="small"><span class="muted">scan:</span> {{.Scan}}</p>{{end}}
|
||||||
@@ -31,6 +32,21 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</table></div>
|
||||||
|
{{$dedup := index $.Data.PoolDedup .Name}}
|
||||||
|
<details class="dedup dedup-{{.Name}}">
|
||||||
|
<summary>Deduplication</summary>
|
||||||
|
<form method="post" action="/zfs/dedup" class="inline">
|
||||||
|
<input type="hidden" name="pool" value="{{.Name}}">
|
||||||
|
<label>Dedup for {{.Name}}
|
||||||
|
<select name="dedup">
|
||||||
|
<option value="off" {{if or (eq $dedup "off") (eq $dedup "")}}selected{{end}}>off</option>
|
||||||
|
<option value="on" {{if and (ne $dedup "off") (ne $dedup "")}}selected{{end}}>on</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Apply</button>
|
||||||
|
</form>
|
||||||
|
<p class="small muted">Sets dedup on the pool root dataset; child datasets inherit it unless they override it. Only newly written data is deduplicated, and turning it off keeps the existing dedup table. The dedup table needs a lot of RAM (up to ~5 GB per 1 TB of unique data) — enable it only for data with many duplicate blocks.</p>
|
||||||
|
</details>
|
||||||
<details class="newds newds-{{.Name}}" {{if eq $form.Pool .Name}}open{{end}}>
|
<details class="newds newds-{{.Name}}" {{if eq $form.Pool .Name}}open{{end}}>
|
||||||
<summary>New dataset in {{.Name}}</summary>
|
<summary>New dataset in {{.Name}}</summary>
|
||||||
<form method="post" action="/zfs/create" class="ds-form">
|
<form method="post" action="/zfs/create" class="ds-form">
|
||||||
@@ -46,6 +62,13 @@
|
|||||||
<option value="off" {{if eq $form.Compression "off"}}selected{{end}}>off</option>
|
<option value="off" {{if eq $form.Compression "off"}}selected{{end}}>off</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>Dedup
|
||||||
|
<select name="dedup">
|
||||||
|
<option value="" {{if eq $form.Dedup ""}}selected{{end}}>inherit</option>
|
||||||
|
<option value="on" {{if eq $form.Dedup "on"}}selected{{end}}>on</option>
|
||||||
|
<option value="off" {{if eq $form.Dedup "off"}}selected{{end}}>off</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label>Quota <input name="quota" placeholder="none, or 100G / 1.5T" value="{{if eq $form.Pool .Name}}{{$form.Quota}}{{end}}"></label>
|
<label>Quota <input name="quota" placeholder="none, or 100G / 1.5T" value="{{if eq $form.Pool .Name}}{{$form.Quota}}{{end}}"></label>
|
||||||
</div>
|
</div>
|
||||||
<p class="small muted">Creates {{.Name}}/<name> (parents are created too), mounts it at /{{.Name}}/<name> and chowns the mountpoint to the owner.</p>
|
<p class="small muted">Creates {{.Name}}/<name> (parents are created too), mounts it at /{{.Name}}/<name> and chowns the mountpoint to the owner.</p>
|
||||||
|
|||||||
@@ -68,12 +68,12 @@ func newTestServer(t *testing.T) (*Server, *sched.Scheduler, *store.Store) {
|
|||||||
},
|
},
|
||||||
Pools: []collect.Pool{
|
Pools: []collect.Pool{
|
||||||
{Name: "tank", Health: "DEGRADED", Size: 8 << 40, Alloc: 3 << 40, Free: 5 << 40, CapPct: 37, FragPct: 5, Scan: "scrub repaired 0B", LastScrub: &old,
|
{Name: "tank", Health: "DEGRADED", Size: 8 << 40, Alloc: 3 << 40, Free: 5 << 40, CapPct: 37, FragPct: 5, Scan: "scrub repaired 0B", LastScrub: &old,
|
||||||
Status: "One or more devices could not be opened.", Action: "Attach the missing device", Errors: "No known data errors",
|
Status: "One or more devices could not be opened.", Action: "Attach the missing device", Errors: "No known data errors", DedupRatio: 1.42,
|
||||||
Vdevs: []collect.Vdev{{Name: "tank", State: "DEGRADED"}, {Name: "mirror-0", Depth: 1, State: "DEGRADED"}, {Name: "sda", Depth: 2, State: "ONLINE", Cksum: 2}, {Name: "123", Depth: 2, State: "UNAVAIL", Note: "was /dev/sdb1"}}},
|
Vdevs: []collect.Vdev{{Name: "tank", State: "DEGRADED"}, {Name: "mirror-0", Depth: 1, State: "DEGRADED"}, {Name: "sda", Depth: 2, State: "ONLINE", Cksum: 2}, {Name: "123", Depth: 2, State: "UNAVAIL", Note: "was /dev/sdb1"}}},
|
||||||
{Name: "tmp_pool", Health: "ONLINE", Size: 1 << 40, Alloc: 900 << 30, Free: 124 << 30, CapPct: 88, Scrubbing: true, Scan: "scrub in progress"},
|
{Name: "tmp_pool", Health: "ONLINE", Size: 1 << 40, Alloc: 900 << 30, Free: 124 << 30, CapPct: 88, Scrubbing: true, Scan: "scrub in progress"},
|
||||||
},
|
},
|
||||||
Datasets: []collect.Dataset{
|
Datasets: []collect.Dataset{
|
||||||
{Name: "tank", Used: 3 << 40, Avail: 5 << 40, Refer: 1 << 20, Mountpoint: "/tank"},
|
{Name: "tank", Used: 3 << 40, Avail: 5 << 40, Refer: 1 << 20, Mountpoint: "/tank", Dedup: "on"},
|
||||||
{Name: "tank/empty", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/empty"},
|
{Name: "tank/empty", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/empty"},
|
||||||
{Name: "tank/data", Used: 3 << 40, Avail: 5 << 40, Refer: 3 << 40, Mountpoint: "/tank/data"},
|
{Name: "tank/data", Used: 3 << 40, Avail: 5 << 40, Refer: 3 << 40, Mountpoint: "/tank/data"},
|
||||||
{Name: "tank/parent", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/parent"},
|
{Name: "tank/parent", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/parent"},
|
||||||
@@ -89,7 +89,18 @@ func newTestServer(t *testing.T) (*Server, *sched.Scheduler, *store.Store) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
srv := New(cfg, st, ring, sc, shs)
|
srv := New(cfg, st, ring, sc, shs)
|
||||||
srv.dirExists = func(p string) bool { return strings.HasPrefix(p, "/tank") || strings.HasPrefix(p, "/tmp_pool") }
|
srv.dirExists = func(p string) bool {
|
||||||
|
return strings.HasPrefix(p, "/tank") || strings.HasPrefix(p, "/tmp_pool") || strings.HasPrefix(p, "/srv")
|
||||||
|
}
|
||||||
|
srv.realPath = func(p string) (string, error) {
|
||||||
|
if p == "/srv/evil" { // symlink into a system directory
|
||||||
|
return "/etc", nil
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
srv.readMounts = func() string {
|
||||||
|
return "proc /proc proc rw 0 0\n/dev/sda2 / ext4 rw 0 0\ntank/data /tank/data zfs rw 0 0\n/dev/sdc1 /mnt/usb ext4 rw 0 0\n"
|
||||||
|
}
|
||||||
return srv, sc, st
|
return srv, sc, st
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +245,7 @@ func TestZfsPageControls(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/zfs?msg=hello", nil))
|
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/zfs?msg=hello", nil))
|
||||||
body := rec.Body.String()
|
body := rec.Body.String()
|
||||||
for _, want := range []string{"New dataset in tank", "New dataset in tmp_pool", "hello", `value="1000:1000"`, "type tank/empty to confirm"} {
|
for _, want := range []string{"New dataset in tank", "New dataset in tmp_pool", "hello", `value="1000:1000"`, "type tank/empty to confirm", `action="/zfs/dedup"`, "Dedup for tmp_pool", "ratio 1.42x"} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("zfs page missing %q", want)
|
t.Errorf("zfs page missing %q", want)
|
||||||
}
|
}
|
||||||
@@ -263,6 +274,7 @@ func TestZfsCreate(t *testing.T) {
|
|||||||
{"pool": {"tank"}, "name": {"x"}, "quota": {"lots"}}, // bad quota
|
{"pool": {"tank"}, "name": {"x"}, "quota": {"lots"}}, // bad quota
|
||||||
{"pool": {"tank"}, "name": {"x"}, "owner": {"ansible"}}, // bad owner
|
{"pool": {"tank"}, "name": {"x"}, "owner": {"ansible"}}, // bad owner
|
||||||
{"pool": {"tank"}, "name": {"x"}, "compression": {"gzip"}},
|
{"pool": {"tank"}, "name": {"x"}, "compression": {"gzip"}},
|
||||||
|
{"pool": {"tank"}, "name": {"x"}, "dedup": {"verify"}},
|
||||||
}
|
}
|
||||||
for _, form := range bad {
|
for _, form := range bad {
|
||||||
f.calls = nil
|
f.calls = nil
|
||||||
@@ -278,11 +290,11 @@ func TestZfsCreate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rec := postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"/media/video/"}, "owner": {"1000:1000"}, "compression": {"lz4"}, "quota": {"100g"}})
|
rec := postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"/media/video/"}, "owner": {"1000:1000"}, "compression": {"lz4"}, "dedup": {"on"}, "quota": {"100g"}})
|
||||||
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "created") {
|
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "created") {
|
||||||
t.Fatalf("status %d location %q body %s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
|
t.Fatalf("status %d location %q body %s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
|
||||||
}
|
}
|
||||||
want := "zfs create -p -o compression=lz4 -o quota=100G tank/media/video"
|
want := "zfs create -p -o compression=lz4 -o dedup=on -o quota=100G tank/media/video"
|
||||||
if len(f.calls) != 3 || f.calls[0] != want || f.calls[2] != "chown 1000:1000 /tank/media/video" {
|
if len(f.calls) != 3 || f.calls[0] != want || f.calls[2] != "chown 1000:1000 /tank/media/video" {
|
||||||
t.Errorf("calls = %v", f.calls)
|
t.Errorf("calls = %v", f.calls)
|
||||||
}
|
}
|
||||||
@@ -296,6 +308,44 @@ func TestZfsCreate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestZfsDedup(t *testing.T) {
|
||||||
|
srv, _, _ := newTestServer(t)
|
||||||
|
f := &fakeRunner{}
|
||||||
|
srv.admin = zfsadm.New(f.run)
|
||||||
|
|
||||||
|
bad := []url.Values{
|
||||||
|
{"pool": {"nope"}, "dedup": {"on"}}, // unknown pool
|
||||||
|
{"pool": {"tank/data"}, "dedup": {"on"}}, // not a pool
|
||||||
|
{"pool": {"tank"}, "dedup": {"verify"}}, // unsupported value
|
||||||
|
{"pool": {"tank"}}, // missing value
|
||||||
|
}
|
||||||
|
for _, form := range bad {
|
||||||
|
f.calls = nil
|
||||||
|
rec := postForm(srv, "/zfs/dedup", form)
|
||||||
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Dedup not changed") {
|
||||||
|
t.Errorf("%v: status %d, no error shown", form, rec.Code)
|
||||||
|
}
|
||||||
|
if len(f.calls) != 0 {
|
||||||
|
t.Errorf("%v: commands ran: %v", form, f.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := postForm(srv, "/zfs/dedup", url.Values{"pool": {"tmp_pool"}, "dedup": {"on"}})
|
||||||
|
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "#tmp_pool") {
|
||||||
|
t.Fatalf("status %d location %q", rec.Code, rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
if len(f.calls) != 1 || f.calls[0] != "zfs set dedup=on tmp_pool" {
|
||||||
|
t.Errorf("calls = %v", f.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
f = &fakeRunner{fail: "zfs set"}
|
||||||
|
srv.admin = zfsadm.New(f.run)
|
||||||
|
rec = postForm(srv, "/zfs/dedup", url.Values{"pool": {"tank"}, "dedup": {"off"}})
|
||||||
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "cannot do that") {
|
||||||
|
t.Errorf("failure not reported: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestZfsDestroy(t *testing.T) {
|
func TestZfsDestroy(t *testing.T) {
|
||||||
srv, _, _ := newTestServer(t)
|
srv, _, _ := newTestServer(t)
|
||||||
f := &fakeRunner{}
|
f := &fakeRunner{}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package zfsadm performs the few mutating ZFS operations the UI offers
|
// Package zfsadm performs the few mutating ZFS operations the UI offers
|
||||||
// (create / destroy datasets). Commands go through a Runner so they can be
|
// (create / destroy datasets, toggle pool dedup). Commands go through a Runner so they can be
|
||||||
// executed on the host via systemd-run: nasmon runs in a sandboxed mount
|
// executed on the host via systemd-run: nasmon runs in a sandboxed mount
|
||||||
// namespace (ProtectSystem=strict), where a plain "zfs create" would mount the
|
// namespace (ProtectSystem=strict), where a plain "zfs create" would mount the
|
||||||
// new dataset only inside the service and leave the host without it.
|
// new dataset only inside the service and leave the host without it.
|
||||||
@@ -59,6 +59,7 @@ func (a *Admin) ctx() (context.Context, context.CancelFunc) {
|
|||||||
// CreateOpts are the user-visible knobs of "zfs create".
|
// CreateOpts are the user-visible knobs of "zfs create".
|
||||||
type CreateOpts struct {
|
type CreateOpts struct {
|
||||||
Compression string // "", lz4, zstd, off ("" = inherit)
|
Compression string // "", lz4, zstd, off ("" = inherit)
|
||||||
|
Dedup string // "", on, off ("" = inherit)
|
||||||
Quota string // "", or a size like 100G / 1.5T
|
Quota string // "", or a size like 100G / 1.5T
|
||||||
Owner string // uid:gid applied to the mountpoint; "" = leave root
|
Owner string // uid:gid applied to the mountpoint; "" = leave root
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,9 @@ func (a *Admin) Create(full string, o CreateOpts) error {
|
|||||||
if err := ValidateCompression(o.Compression); err != nil {
|
if err := ValidateCompression(o.Compression); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := ValidateDedup(o.Dedup); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := ValidateSize(o.Quota); err != nil {
|
if err := ValidateSize(o.Quota); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -81,6 +85,9 @@ func (a *Admin) Create(full string, o CreateOpts) error {
|
|||||||
if o.Compression != "" {
|
if o.Compression != "" {
|
||||||
args = append(args, "-o", "compression="+o.Compression)
|
args = append(args, "-o", "compression="+o.Compression)
|
||||||
}
|
}
|
||||||
|
if o.Dedup != "" {
|
||||||
|
args = append(args, "-o", "dedup="+o.Dedup)
|
||||||
|
}
|
||||||
if o.Quota != "" {
|
if o.Quota != "" {
|
||||||
args = append(args, "-o", "quota="+o.Quota)
|
args = append(args, "-o", "quota="+o.Quota)
|
||||||
}
|
}
|
||||||
@@ -106,6 +113,25 @@ func (a *Admin) Create(full string, o CreateOpts) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDedup sets the dedup property (on/off) on the root dataset of pool, so it
|
||||||
|
// applies to every dataset that does not override it. Only newly written
|
||||||
|
// blocks are affected either way.
|
||||||
|
func (a *Admin) SetDedup(pool, value string) error {
|
||||||
|
if err := ValidateFullName(pool); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.Contains(pool, "/") {
|
||||||
|
return errors.New("dedup is set on the pool root dataset only")
|
||||||
|
}
|
||||||
|
if value != "on" && value != "off" {
|
||||||
|
return errors.New("dedup must be on or off")
|
||||||
|
}
|
||||||
|
ctx, cancel := a.ctx()
|
||||||
|
defer cancel()
|
||||||
|
_, err := a.Run(ctx, "zfs", "set", "dedup="+value, pool)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// EmptyRefer is the upper bound of "referenced" bytes for a dataset we still
|
// EmptyRefer is the upper bound of "referenced" bytes for a dataset we still
|
||||||
// consider empty (a fresh dataset refers ~96-128 KiB of metadata).
|
// consider empty (a fresh dataset refers ~96-128 KiB of metadata).
|
||||||
const EmptyRefer = 1 << 20
|
const EmptyRefer = 1 << 20
|
||||||
@@ -206,6 +232,14 @@ func ValidateCompression(s string) error {
|
|||||||
return errors.New("compression must be inherit, lz4, zstd or off")
|
return errors.New("compression must be inherit, lz4, zstd or off")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidateDedup(s string) error {
|
||||||
|
switch s {
|
||||||
|
case "", "on", "off":
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("dedup must be inherit, on or off")
|
||||||
|
}
|
||||||
|
|
||||||
func ValidateOwner(s string) error {
|
func ValidateOwner(s string) error {
|
||||||
if s == "" || reOwner.MatchString(s) {
|
if s == "" || reOwner.MatchString(s) {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ func TestValidators(t *testing.T) {
|
|||||||
if ValidateCompression("gzip") == nil || ValidateCompression("lz4") != nil || ValidateCompression("") != nil {
|
if ValidateCompression("gzip") == nil || ValidateCompression("lz4") != nil || ValidateCompression("") != nil {
|
||||||
t.Error("compression validation")
|
t.Error("compression validation")
|
||||||
}
|
}
|
||||||
|
if ValidateDedup("verify") == nil || ValidateDedup("on") != nil || ValidateDedup("") != nil {
|
||||||
|
t.Error("dedup validation")
|
||||||
|
}
|
||||||
if ValidateOwner("ansible") == nil || ValidateOwner("1000:1000") != nil || ValidateOwner("") != nil {
|
if ValidateOwner("ansible") == nil || ValidateOwner("1000:1000") != nil || ValidateOwner("") != nil {
|
||||||
t.Error("owner validation")
|
t.Error("owner validation")
|
||||||
}
|
}
|
||||||
@@ -74,6 +77,16 @@ func TestCreate(t *testing.T) {
|
|||||||
t.Errorf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
|
t.Errorf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dedup goes between compression and quota
|
||||||
|
f = &fake{}
|
||||||
|
a = New(f.run)
|
||||||
|
if err := a.Create("tank/d", CreateOpts{Compression: "zstd", Dedup: "on", Quota: "1T"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(f.calls) != 1 || f.calls[0] != "zfs create -p -o compression=zstd -o dedup=on -o quota=1T tank/d" {
|
||||||
|
t.Errorf("calls = %v", f.calls)
|
||||||
|
}
|
||||||
|
|
||||||
// no owner, inherit everything
|
// no owner, inherit everything
|
||||||
f = &fake{}
|
f = &fake{}
|
||||||
a = New(f.run)
|
a = New(f.run)
|
||||||
@@ -115,6 +128,28 @@ func TestCreate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetDedup(t *testing.T) {
|
||||||
|
f := &fake{}
|
||||||
|
a := New(f.run)
|
||||||
|
for _, c := range [][2]string{{"tank/x", "on"}, {"tank", "verify"}, {"tank", ""}, {"-tank", "on"}, {"", "off"}} {
|
||||||
|
if err := a.SetDedup(c[0], c[1]); err == nil {
|
||||||
|
t.Errorf("%q=%q accepted", c[0], c[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(f.calls) != 0 {
|
||||||
|
t.Errorf("rejected input ran commands: %v", f.calls)
|
||||||
|
}
|
||||||
|
if err := a.SetDedup("tank", "on"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.SetDedup("tank", "off"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(f.calls, "|") != "zfs set dedup=on tank|zfs set dedup=off tank" {
|
||||||
|
t.Errorf("calls = %v", f.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDestroyEmpty(t *testing.T) {
|
func TestDestroyEmpty(t *testing.T) {
|
||||||
ok := map[string]string{"zfs list -Hp -r -t filesystem": "tank/x\t98304", "zfs list -H -t snapshot": ""}
|
ok := map[string]string{"zfs list -Hp -r -t filesystem": "tank/x\t98304", "zfs list -H -t snapshot": ""}
|
||||||
f := &fake{reply: ok}
|
f := &fake{reply: ok}
|
||||||
|
|||||||
+11
-3
@@ -14,8 +14,11 @@ periodic SMART/ZFS checks with e-mail alerts.
|
|||||||
|
|
||||||
The ZFS page (http://<host>:8090/zfs) can create datasets in a pool — name (nested
|
The ZFS page (http://<host>:8090/zfs) can create datasets in a pool — name (nested
|
||||||
names like media/video are fine), owner uid:gid for the mountpoint, compression,
|
names like media/video are fine), owner uid:gid for the mountpoint, compression,
|
||||||
optional quota — and destroy datasets that are empty (no data, no children, no
|
dedup, optional quota — and destroy datasets that are empty (no data, no children, no
|
||||||
snapshots; the check is repeated against the live system before "zfs destroy").
|
snapshots; the check is repeated against the live system before "zfs destroy").
|
||||||
|
Each pool card also shows the dedup ratio and can turn dedup on/off on the pool
|
||||||
|
root dataset (`zfs set dedup=on|off <pool>`, inherited by child datasets; only new
|
||||||
|
writes are affected, and the dedup table needs a lot of RAM).
|
||||||
|
|
||||||
nasmon runs sandboxed (ProtectSystem=strict → own mount namespace), so these
|
nasmon runs sandboxed (ProtectSystem=strict → own mount namespace), so these
|
||||||
commands are executed on the host through transient units:
|
commands are executed on the host through transient units:
|
||||||
@@ -29,11 +32,16 @@ nasmon has no authentication: keep port 8090 on the LAN only.
|
|||||||
http://<host>:8090/shares manages Samba shares (guest or per-user, files always
|
http://<host>:8090/shares manages Samba shares (guest or per-user, files always
|
||||||
written as the data owner uid 1000), SMB users (smbpasswd; a login-less Unix
|
written as the data owner uid 1000), SMB users (smbpasswd; a login-less Unix
|
||||||
account is created when needed) and NFS exports (host/IP/CIDR clients, rw/ro,
|
account is created when needed) and NFS exports (host/IP/CIDR clients, rw/ro,
|
||||||
squash). Definitions are stored in /etc/nasmon/shares.json; nasmon regenerates
|
squash). A share can point to any existing folder (ZFS dataset, USB disk under
|
||||||
|
/mnt or /media, /srv…) except system directories (/etc, /usr, /var, /boot…),
|
||||||
|
also after resolving symlinks. Definitions are stored in
|
||||||
|
/etc/nasmon/shares.json; nasmon regenerates
|
||||||
/etc/samba/smb.conf (whole file, header "Managed by nasmon") and
|
/etc/samba/smb.conf (whole file, header "Managed by nasmon") and
|
||||||
/etc/exports.d/nasmon.exports, validates with testparm, then reloads smbd /
|
/etc/exports.d/nasmon.exports, validates with testparm, then reloads smbd /
|
||||||
exportfs -ra. A rejected config is rolled back to the .bak copy. Daemons are
|
exportfs -ra. A rejected config is rolled back to the .bak copy. Daemons are
|
||||||
enabled while at least one share exists and disabled otherwise.
|
enabled while at least one share exists and disabled otherwise. "Allow legacy
|
||||||
|
clients" in the SMB settings turns on SMB1 + NTLMv1 (server min protocol = NT1)
|
||||||
|
for IPMI/BMC virtual media and other old devices; it is off by default.
|
||||||
|
|
||||||
Alerts: the × on an open alert acknowledges it (hidden and not mailed until the
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user