diff --git a/CHANGELOG.md b/CHANGELOG.md
index 71db6e1..02ec479 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,34 @@
## Changelog
+### v0.115.0 — version-aware Supports (agent version channel) + DSM-validated guidance (2026-07-11) — MinAgent: 0.81.0
+
+Capability detection upgrades from route-probing to explicit version comparison, riding agent
+v0.82.0's `X-Felhom-Agent-Version` response header. **The probe FALLS BACK cleanly — agent 0.82 is
+NOT required** (MinAgent stays 0.81.0: the coupled NAS semantics; Peti's 0.81.0 box exercises the
+fallback in production).
+
+- **agentapi**: every response path passively captures the header (`noteAgentVersion` on all four
+ `Do` sites — even on 404s/errors); STRICT bare-semver validation at capture (the publish-agent.sh
+ shape; garbage never overwrites); `Client.AgentVersion()` exposes the last-seen value.
+- **features.go**: per-feature `featureMinAgent` table (`netstorage_verify: 0.81.0`) + optional
+ `AgentVersionReporter` on the prober. `Supports` order: version known → semver compare →
+ Yes/No with ZERO probe traffic; version unknown/garbage/table-gap → the v0.114.0 probe path
+ byte-identical (SupportCache stays probe-only). Gate/banner/UI unchanged — same three verdicts,
+ better source.
+- **THE one comparator**: `selfupdate.ParseVersion/Version.Compare` moved verbatim to
+ `internal/util/version.go` (selfupdate keeps type aliases — call sites + tests byte-unchanged);
+ agentapi shares it (no import cycle, no second comparator).
+- **DSM-validated NAS guidance** (SPIKE-nas-dsm-2026-07-11, real DSM 7.2 via virtual-dsm): the NFS
+ guidance gains the verified Synology steps — File Services → NFS → enable + **Maximum NFS
+ protocol: NFSv4.1** (the v3 default refuses our mount), NFS Permissions rule with Squash
+ „Map all users to admin”, the `/volume1/` path hint; the "útmutató készül" caveat narrows
+ to **QNAP only** (Synology now validated end-to-end incl. SMB hardlink).
+- Tests + red-proofs: version-known compares without probing (mutant: short-circuit dropped →
+ probes=1); garbage/absent header → exactly-one-probe fallback + cached (mutant: trusting an
+ unparseable header as "too old" → fails); non-reporter probers byte-unchanged; comparator table
+ incl. pre-release rejection + numeric-vs-lexicographic; wire-level: header wins over a routeless
+ agent through the real pinned client, garbage header ignored at capture.
+
### v0.114.0 — agent-capability gate for coupled features (2026-07-11) — MinAgent: —
Box-level backstop for the publish-train ordering discipline (incident: the 0.81/0.113 train's
diff --git a/REUSE.md b/REUSE.md
index fe8af8f..02860f8 100644
--- a/REUSE.md
+++ b/REUSE.md
@@ -175,6 +175,8 @@
|---|---|---|---|
| `diskAgent` | controller/internal/web/storage_handlers.go | `*agentapi.Client` | `mockAgent` in controller/internal/web/storage_handlers_test.go |
| `netAgent` + `Server.netAgentFn/netProbeFn/netListFn` | controller/internal/web/netstorage_job.go (+ server.go fields) | `*agentapi.Client` / `runNetProbe` (linux re-exec) / `agent.ListNetStorage` | `fakeNetAgent` + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests |
+| `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) |
+| `agentapi.AgentVersionReporter` + `featureMinAgent` | controller/internal/agentapi/features.go | version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents | a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row |
| `netProbeReadBack` (package var) | controller/internal/web/netprobe.go | `os.ReadFile` | overridden in TestNetProbeChild (nonce-tamper + cleanup-fail rows); package var because the child is a RE-EXEC'd process in production |
| `quiesce.Backend` / `quiesce.Stacks` | controller/internal/quiesce/quiesce.go | adapter over `*agentapi.Client` / `*stacks.Manager` | `fakeBackend`/`fakeStacks` in controller/internal/quiesce/quiesce_test.go |
| `channelhealth.Probe` (func) + `Sink` | controller/internal/channelhealth/checker.go | `Server.ProbeAgentChannel` / notifier adapter | `fakeSink` in controller/internal/channelhealth/checker_test.go |
diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go
index 14f8f04..bddd2da 100644
--- a/controller/internal/agentapi/client.go
+++ b/controller/internal/agentapi/client.go
@@ -17,7 +17,9 @@ import (
"fmt"
"io"
"net/http"
+ "regexp"
"strings"
+ "sync"
"time"
)
@@ -28,6 +30,36 @@ type Client struct {
hc *http.Client
// features caches capability-probe verdicts for Supports (features.go).
features SupportCache
+ // verMu guards lastAgentVersion — the most recent STRICTLY-VALIDATED X-Felhom-Agent-Version
+ // seen on any agent response (v0.82.0 version channel). "" = never seen (pre-0.82 agent) →
+ // Supports falls back to the route probe.
+ verMu sync.Mutex
+ lastAgentVersion string
+}
+
+// reAgentVersion is the bare-semver shape the publish pipeline enforces (publish-agent.sh) — the
+// ONLY header values trusted for capability comparison. Anything else (garbage, "dev", suffixes)
+// is ignored and the probe fallback stays in charge.
+var reAgentVersion = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
+
+// noteAgentVersion records a response's version header (passive capture — called on EVERY response
+// path). Invalid/absent headers never overwrite a previously-seen valid version.
+func (c *Client) noteAgentVersion(resp *http.Response) {
+ v := strings.TrimSpace(resp.Header.Get("X-Felhom-Agent-Version"))
+ if v == "" || !reAgentVersion.MatchString(v) {
+ return
+ }
+ c.verMu.Lock()
+ c.lastAgentVersion = v
+ c.verMu.Unlock()
+}
+
+// AgentVersion returns the last strictly-validated agent version seen on this client's traffic
+// ("" = unknown — header-less agent or no traffic yet). This is the Supports comparison source.
+func (c *Client) AgentVersion() string {
+ c.verMu.Lock()
+ defer c.verMu.Unlock()
+ return c.lastAgentVersion
}
// MountInfo mirrors the agent's GET /storage mount entry (doc 03 §6).
@@ -491,6 +523,7 @@ func (c *Client) WipeStagedEscrowSecret(ctx context.Context) error {
return fmt.Errorf("agentapi: DELETE /escrow/stage-secret: %w", err)
}
defer resp.Body.Close()
+ c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var env apiResponse
if err := json.Unmarshal(raw, &env); err != nil {
@@ -771,6 +804,7 @@ func (c *Client) postWithStatus(ctx context.Context, path string, body any) (api
return env, 0, fmt.Errorf("agentapi: POST %s: %w", path, err)
}
defer resp.Body.Close()
+ c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err := json.Unmarshal(raw, &env); err != nil {
return env, resp.StatusCode, fmt.Errorf("agentapi: POST %s: HTTP %d, bad envelope: %w", path, resp.StatusCode, err)
@@ -879,6 +913,7 @@ func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error)
return nil, fmt.Errorf("agentapi: GET %s: %w", path, err)
}
defer resp.Body.Close()
+ c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return nil, &StatusError{Path: path, Code: resp.StatusCode}
@@ -911,6 +946,7 @@ func (c *Client) post(ctx context.Context, path string, body any) (json.RawMessa
return nil, fmt.Errorf("agentapi: POST %s: %w", path, err)
}
defer resp.Body.Close()
+ c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
return nil, fmt.Errorf("agentapi: POST %s: HTTP %d", path, resp.StatusCode)
diff --git a/controller/internal/agentapi/features.go b/controller/internal/agentapi/features.go
index 84c6b7f..a4bb15d 100644
--- a/controller/internal/agentapi/features.go
+++ b/controller/internal/agentapi/features.go
@@ -6,6 +6,8 @@ import (
"net/http"
"sync"
"time"
+
+ "gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// Agent-capability probing (the publish-train backstop). A controller release that depends on
@@ -53,13 +55,12 @@ type SupportProber interface {
NetVerifyStatus(ctx context.Context) (NetVerifyStatus, error)
}
-// featureProbes maps each coupled feature to its route probe.
+// featureProbes maps each coupled feature to its route probe (the FALLBACK path for agents that
+// predate the v0.82.0 version header).
//
// CONVENTION (publish-train rules doc, felhom.eu/documentation/runbooks/publish-train-rules.md):
-// every future coupled feature adds a row here plus a Supports gate call at its entry point, and
-// declares MinAgent in its CHANGELOG header. When the agent someday reports an explicit version in
-// its envelope, Supports should prefer that version comparison over route probing — that
-// enhancement is roadmap, not built yet.
+// every future coupled feature adds a row here AND a featureMinAgent row, plus a Supports gate
+// call at its entry point, and declares MinAgent in its CHANGELOG header.
var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error{
FeatureNetstorageVerify: func(ctx context.Context, p SupportProber) error {
_, err := p.NetVerifyStatus(ctx)
@@ -67,6 +68,20 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
},
}
+// featureMinAgent maps each coupled feature to the MINIMUM agent version that carries its coupled
+// semantics (the CHANGELOG `MinAgent:` header value). Used by Supports when the agent's version is
+// KNOWN (the v0.82.0 X-Felhom-Agent-Version channel) — a direct comparison, no probe traffic. A
+// feature missing here (or an unparseable table value) falls back to the probe.
+var featureMinAgent = map[Feature]string{
+ FeatureNetstorageVerify: "0.81.0",
+}
+
+// AgentVersionReporter is optionally implemented by a SupportProber (*Client is one): it reports
+// the last strictly-validated agent version seen on its traffic ("" = unknown → probe fallback).
+type AgentVersionReporter interface {
+ AgentVersion() string
+}
+
// supportTTL bounds how long a probe verdict (either polarity) is trusted. An agent updated
// mid-window flips within this — no invalidation plumbing by design.
const supportTTL = 5 * time.Minute
@@ -85,14 +100,24 @@ type SupportCache struct {
entries map[Feature]supportEntry
}
-// Supports reports whether the agent behind p provides feature f, answering from the cache inside
-// the TTL window and probing otherwise. The probe runs OUTSIDE the lock — concurrent misses may
-// double-probe (harmless: the probe is one cheap GET).
+// Supports reports whether the agent behind p provides feature f.
+//
+// Order (v0.115.0): (1) the agent's VERSION is known (the v0.82.0 header channel, strictly
+// validated at capture) AND the feature has a MinAgent row → pure semver comparison, NO probe
+// traffic, no cache involvement (the cache stays probe-only); (2) otherwise — pre-0.82 agent, no
+// traffic yet, or a table gap — the v0.114.0 probe path, byte-identical (cache inside the TTL
+// window, probe on miss). The probe runs OUTSIDE the lock — concurrent misses may double-probe
+// (harmless: the probe is one cheap GET).
func (sc *SupportCache) Supports(ctx context.Context, p SupportProber, f Feature) SupportState {
probe, ok := featureProbes[f]
if !ok {
return SupportUnknown // unregistered feature — never refuse on a table gap
}
+ if vr, hasVer := p.(AgentVersionReporter); hasVer {
+ if state, decided := supportsByVersion(vr.AgentVersion(), f); decided {
+ return state
+ }
+ }
sc.mu.Lock()
nowFn := sc.now
if nowFn == nil {
@@ -123,6 +148,31 @@ func (c *Client) Supports(ctx context.Context, f Feature) SupportState {
return c.features.Supports(ctx, c, f)
}
+// supportsByVersion decides a feature by version comparison alone. decided=false (unknown/garbage
+// version, missing MinAgent row, unparseable table value) sends the caller to the probe fallback —
+// a bad version string must never be trusted in EITHER direction.
+func supportsByVersion(agentVer string, f Feature) (state SupportState, decided bool) {
+ if agentVer == "" {
+ return SupportUnknown, false
+ }
+ minStr, ok := featureMinAgent[f]
+ if !ok {
+ return SupportUnknown, false
+ }
+ av, err := util.ParseVersion(agentVer)
+ if err != nil {
+ return SupportUnknown, false // capture validates the shape, but stay defensive
+ }
+ mv, err := util.ParseVersion(minStr)
+ if err != nil {
+ return SupportUnknown, false // a broken table row falls back to probing, never refuses
+ }
+ if av.Compare(mv) >= 0 {
+ return SupportYes, true
+ }
+ return SupportNo, true
+}
+
// classifySupportErr maps a probe outcome to a SupportState. ONLY a typed HTTP 404 means
// "unsupported" — every other error (transport, timeout, 401, 5xx, envelope problems) is Unknown,
// so a merely-down agent is never reported as outdated. Typed errors only; never string-match.
diff --git a/controller/internal/agentapi/features_version_test.go b/controller/internal/agentapi/features_version_test.go
new file mode 100644
index 0000000..3660964
--- /dev/null
+++ b/controller/internal/agentapi/features_version_test.go
@@ -0,0 +1,179 @@
+package agentapi
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "gitea.dooplex.hu/admin/felhom-controller/internal/util"
+)
+
+// Version-aware Supports (v0.115.0, pairs with agent v0.82.0's X-Felhom-Agent-Version): a KNOWN
+// version decides by comparison with ZERO probe traffic; unknown/garbage stays on the v0.114.0
+// probe path byte-identically.
+
+// verProber implements SupportProber + AgentVersionReporter with a probe counter.
+type verProber struct {
+ ver string
+ probes int
+}
+
+func (p *verProber) NetVerifyStatus(context.Context) (NetVerifyStatus, error) {
+ p.probes++
+ return NetVerifyStatus{Phase: "none"}, nil // a live route (probe verdict would be Yes)
+}
+func (p *verProber) AgentVersion() string { return p.ver }
+
+// --- B3.1: version known → compare path, probe count 0 -----------------------------------------
+// Companion red-proof: drop the supportsByVersion short-circuit in Supports → probes becomes 1.
+func TestSupports_VersionKnown_ComparesWithoutProbe(t *testing.T) {
+ for _, tc := range []struct {
+ ver string
+ want SupportState
+ }{
+ {"0.82.0", SupportYes},
+ {"0.81.0", SupportYes}, // boundary: MinAgent itself qualifies
+ {"1.0.0", SupportYes}, // numeric compare across majors
+ {"0.100.0", SupportYes}, // numeric, NOT lexicographic (0.100 > 0.81)
+ {"0.79.0", SupportNo}, // below MinAgent → No, still without probing
+ {"0.80.9", SupportNo},
+ } {
+ t.Run(tc.ver, func(t *testing.T) {
+ p := &verProber{ver: tc.ver}
+ var sc SupportCache
+ if got := sc.Supports(context.Background(), p, FeatureNetstorageVerify); got != tc.want {
+ t.Errorf("Supports(ver=%s) = %v, want %v", tc.ver, got, tc.want)
+ }
+ if p.probes != 0 {
+ t.Errorf("version-known path must NOT probe (ver=%s, probes=%d)", tc.ver, p.probes)
+ }
+ })
+ }
+}
+
+// --- B3.2: garbage/absent version → the probe fallback, byte-identical --------------------------
+// Companion red-proof: trust the unvalidated header text (compare without ParseVersion error
+// handling) → the garbage rows would refuse or panic instead of probing.
+func TestSupports_GarbageOrNoVersion_ProbeFallback(t *testing.T) {
+ for _, ver := range []string{"", "dev", "0.82", "0.82.0-rc1", "v0.82.0-beta", "evil;rm -rf", "9999999999999999999999.0.0"} {
+ t.Run("ver="+ver, func(t *testing.T) {
+ p := &verProber{ver: ver}
+ var sc SupportCache
+ got := sc.Supports(context.Background(), p, FeatureNetstorageVerify)
+ if p.probes != 1 {
+ t.Fatalf("unusable version %q must fall back to EXACTLY one probe, got %d", ver, p.probes)
+ }
+ if got != SupportYes { // the fake's route answers → the probe decides Yes
+ t.Errorf("probe fallback verdict = %v, want SupportYes", got)
+ }
+ // Second call: the probe verdict is cached (the v0.114.0 behavior, unchanged).
+ _ = sc.Supports(context.Background(), p, FeatureNetstorageVerify)
+ if p.probes != 1 {
+ t.Errorf("cached probe verdict must not re-probe (probes=%d)", p.probes)
+ }
+ })
+ }
+}
+
+// A prober that does NOT implement AgentVersionReporter (the web fakes' shape) keeps the pure
+// v0.114.0 behavior — the interface assertion must not change anything for it.
+func TestSupports_NonReporterProber_Unchanged(t *testing.T) {
+ p := &fakeProber{} // the existing v0.114.0 test fake (features_test.go)
+ var sc SupportCache
+ if got := sc.Supports(context.Background(), p, FeatureNetstorageVerify); got != SupportYes {
+ t.Fatalf("non-reporter prober = %v, want SupportYes via probe", got)
+ }
+ if p.calls != 1 {
+ t.Errorf("non-reporter prober must probe exactly once, got %d", p.calls)
+ }
+}
+
+// --- B3.4: the ONE comparator, table-driven (incl. pre-release suffixes) ------------------------
+func TestVersionComparator_Table(t *testing.T) {
+ lt := func(a, b string) {
+ t.Helper()
+ av, err1 := util.ParseVersion(a)
+ bv, err2 := util.ParseVersion(b)
+ if err1 != nil || err2 != nil {
+ t.Fatalf("parse %q/%q: %v %v", a, b, err1, err2)
+ }
+ if av.Compare(bv) != -1 || bv.Compare(av) != 1 {
+ t.Errorf("want %s < %s", a, b)
+ }
+ }
+ lt("0.81.0", "0.82.0")
+ lt("0.81.0", "0.100.0") // numeric minor, not lexicographic
+ lt("0.99.9", "1.0.0")
+ lt("1.2.3", "1.2.10")
+ if v, err := util.ParseVersion("v0.82.0"); err != nil || v.Raw != "0.82.0" {
+ t.Errorf("v-prefix must parse: %v %v", v, err)
+ }
+ if eq, _ := util.ParseVersion("0.81.0"); eq.Compare(eq) != 0 {
+ t.Error("equal versions must compare 0")
+ }
+ // Pre-release suffixes are REJECTED by the house comparator — in Supports they mean "fall back
+ // to the probe", never a trusted comparison.
+ for _, bad := range []string{"1.2.3-rc1", "dev", "latest", "", "1.2", "a.b.c"} {
+ if _, err := util.ParseVersion(bad); err == nil {
+ t.Errorf("ParseVersion(%q) must error", bad)
+ }
+ }
+}
+
+// --- B3.5: wire-level — the header decides over the probe through the REAL pinned client --------
+// A ROUTELESS agent (probe would say No) that sends X-Felhom-Agent-Version 9.9.9 must be judged by
+// the VERSION (Yes): proof the channel takes precedence end-to-end, and that capture happens on an
+// ordinary (even failing) call.
+func TestClient_VersionHeaderWins_WireLevel(t *testing.T) {
+ mux := http.NewServeMux() // NO /netstorage/verify-status (the pre-0.81 route shape)
+ mux.HandleFunc("/storage", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Felhom-Agent-Version", "9.9.9")
+ _, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":1,"mounts":[]}}`))
+ })
+ wrapped := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Felhom-Agent-Version", "9.9.9") // every response, like the agent's wrap
+ mux.ServeHTTP(w, r)
+ })
+ srv := httptest.NewTLSServer(wrapped)
+ defer srv.Close()
+ fp := sha256.Sum256(srv.Certificate().Raw)
+ c, err := New(strings.TrimPrefix(srv.URL, "https://"), "test-token", hex.EncodeToString(fp[:]))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if _, err := c.Storage(context.Background()); err != nil {
+ t.Fatalf("storage: %v", err)
+ }
+ if got := c.AgentVersion(); got != "9.9.9" {
+ t.Fatalf("AgentVersion = %q, want 9.9.9 (passive capture)", got)
+ }
+ if got := c.Supports(context.Background(), FeatureNetstorageVerify); got != SupportYes {
+ t.Errorf("Supports = %v, want SupportYes via version despite the missing probe route", got)
+ }
+}
+
+// A GARBAGE header must never be captured: the routeless agent stays on the probe → SupportNo.
+func TestClient_GarbageHeaderIgnored_WireLevel(t *testing.T) {
+ wrapped := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Felhom-Agent-Version", "not-a-version;x")
+ http.NotFound(w, r)
+ })
+ srv := httptest.NewTLSServer(wrapped)
+ defer srv.Close()
+ fp := sha256.Sum256(srv.Certificate().Raw)
+ c, err := New(strings.TrimPrefix(srv.URL, "https://"), "test-token", hex.EncodeToString(fp[:]))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ _, _ = c.Storage(context.Background()) // 404s; the garbage header must be dropped at capture
+ if got := c.AgentVersion(); got != "" {
+ t.Fatalf("AgentVersion = %q, want empty (strict validation at capture)", got)
+ }
+ if got := c.Supports(context.Background(), FeatureNetstorageVerify); got != SupportNo {
+ t.Errorf("Supports = %v, want SupportNo via the probe fallback", got)
+ }
+}
diff --git a/controller/internal/selfupdate/version.go b/controller/internal/selfupdate/version.go
index b61fb0b..ea3a8c3 100644
--- a/controller/internal/selfupdate/version.go
+++ b/controller/internal/selfupdate/version.go
@@ -1,68 +1,13 @@
package selfupdate
-import (
- "fmt"
- "strconv"
- "strings"
-)
+// Version comparison moved to internal/util/version.go (v0.115.0) so agentapi's MinAgent
+// capability comparison shares THE one comparator without an import cycle (this package imports
+// agentapi). These aliases keep every selfupdate call site and test byte-unchanged.
+
+import "gitea.dooplex.hu/admin/felhom-controller/internal/util"
// Version represents a semantic version (Major.Minor.Patch).
-type Version struct {
- Major int
- Minor int
- Patch int
- Raw string
-}
+type Version = util.Version
// ParseVersion parses "X.Y.Z" or "vX.Y.Z". Returns error for "dev", "latest", or invalid formats.
-func ParseVersion(s string) (Version, error) {
- s = strings.TrimPrefix(s, "v")
- if s == "dev" || s == "latest" || s == "" {
- return Version{}, fmt.Errorf("invalid version: %q", s)
- }
- parts := strings.SplitN(s, ".", 3)
- if len(parts) != 3 {
- return Version{}, fmt.Errorf("invalid version format: %q (expected X.Y.Z)", s)
- }
- major, err := strconv.Atoi(parts[0])
- if err != nil {
- return Version{}, fmt.Errorf("invalid major version: %w", err)
- }
- minor, err := strconv.Atoi(parts[1])
- if err != nil {
- return Version{}, fmt.Errorf("invalid minor version: %w", err)
- }
- patch, err := strconv.Atoi(parts[2])
- if err != nil {
- return Version{}, fmt.Errorf("invalid patch version: %w", err)
- }
- return Version{Major: major, Minor: minor, Patch: patch, Raw: s}, nil
-}
-
-// Compare returns -1 if a < b, 0 if a == b, 1 if a > b.
-func (a Version) Compare(b Version) int {
- if a.Major != b.Major {
- if a.Major < b.Major {
- return -1
- }
- return 1
- }
- if a.Minor != b.Minor {
- if a.Minor < b.Minor {
- return -1
- }
- return 1
- }
- if a.Patch != b.Patch {
- if a.Patch < b.Patch {
- return -1
- }
- return 1
- }
- return 0
-}
-
-// String returns the version as "X.Y.Z".
-func (v Version) String() string {
- return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
-}
+func ParseVersion(s string) (Version, error) { return util.ParseVersion(s) }
diff --git a/controller/internal/util/version.go b/controller/internal/util/version.go
new file mode 100644
index 0000000..da387ab
--- /dev/null
+++ b/controller/internal/util/version.go
@@ -0,0 +1,74 @@
+package util
+
+// Semantic-version parsing + comparison — THE one comparator in this repo (house rule: never a
+// second one). Moved verbatim from internal/selfupdate/version.go (v0.115.0) so that BOTH
+// selfupdate (floor/update decisions) and agentapi (the per-feature MinAgent capability
+// comparison) share it without an import cycle (selfupdate imports agentapi). selfupdate keeps
+// aliases, so its call sites and tests are unchanged.
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// Version represents a semantic version (Major.Minor.Patch).
+type Version struct {
+ Major int
+ Minor int
+ Patch int
+ Raw string
+}
+
+// ParseVersion parses "X.Y.Z" or "vX.Y.Z". Returns error for "dev", "latest", or invalid formats.
+func ParseVersion(s string) (Version, error) {
+ s = strings.TrimPrefix(s, "v")
+ if s == "dev" || s == "latest" || s == "" {
+ return Version{}, fmt.Errorf("invalid version: %q", s)
+ }
+ parts := strings.SplitN(s, ".", 3)
+ if len(parts) != 3 {
+ return Version{}, fmt.Errorf("invalid version format: %q (expected X.Y.Z)", s)
+ }
+ major, err := strconv.Atoi(parts[0])
+ if err != nil {
+ return Version{}, fmt.Errorf("invalid major version: %w", err)
+ }
+ minor, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return Version{}, fmt.Errorf("invalid minor version: %w", err)
+ }
+ patch, err := strconv.Atoi(parts[2])
+ if err != nil {
+ return Version{}, fmt.Errorf("invalid patch version: %w", err)
+ }
+ return Version{Major: major, Minor: minor, Patch: patch, Raw: s}, nil
+}
+
+// Compare returns -1 if a < b, 0 if a == b, 1 if a > b.
+func (a Version) Compare(b Version) int {
+ if a.Major != b.Major {
+ if a.Major < b.Major {
+ return -1
+ }
+ return 1
+ }
+ if a.Minor != b.Minor {
+ if a.Minor < b.Minor {
+ return -1
+ }
+ return 1
+ }
+ if a.Patch != b.Patch {
+ if a.Patch < b.Patch {
+ return -1
+ }
+ return 1
+ }
+ return 0
+}
+
+// String returns the version as "X.Y.Z".
+func (v Version) String() string {
+ return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
+}
diff --git a/controller/internal/web/templates/storage_network.html b/controller/internal/web/templates/storage_network.html
index ff0a276..ee66a07 100644
--- a/controller/internal/web/templates/storage_network.html
+++ b/controller/internal/web/templates/storage_network.html
@@ -113,13 +113,20 @@
megosztásra. Más beállítás nem szükséges — a fájlok a NAS-on ennek a felhasználónak a nevében
jönnek létre.
NFS
- Egyszerű (a legtöbb NAS-hoz): kapcsolja be az exporton a „minden felhasználó leképezése” (map all
+
Synology (DSM, ellenőrzött lépések): először kapcsolja be az NFS-t — Vezérlőpult → File
+ Services → NFS fül → „Enable NFS service”, és állítsa a „Maximum NFS protocol” értékét
+ NFSv4.1-re (az alapértelmezett NFSv3 nem elegendő a csatlakoztatáshoz). Ezután a megosztott
+ mappa szerkesztésében az „NFS Permissions” fülön hozzon létre szabályt a Felhom gép
+ IP-címére, Read/Write jogosultsággal, a Squash mezőben a „Map all users to admin” opcióval.
+ A megosztás elérési útja ugyanezen a fülön látható (pl. /volume1/<mappa>) — ez kerül a
+ fenti „Megosztás” mezőbe.
+ Egyszerű (más NAS-okhoz): kapcsolja be az exporton a „minden felhasználó leképezése” (map all
users / all squash) opciót írás-olvasás móddal — bármelyik helyi felhasználóra. A fájlok
tulajdonosa a rendszerben „nobody”-ként látszik; az alkalmazások túlnyomó többségének ez megfelelő.
Teljes értékű (TrueNAS / Linux szerver): export a következő opciókkal:
rw,all_squash,anonuid=101000,anongid=101000
— így a tulajdonos-információk is hibátlanok.
- A Synology/QNAP felületére szabott lépésről lépésre útmutató készül.
+ A QNAP felületére szabott lépésről lépésre útmutató készül.