From ed0ed78e31ba1e76ac576ec49c306eb523ad2ff9 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 15 Jun 2026 20:14:18 +1200 Subject: [PATCH] =?UTF-8?q?feat:=20NFS=20export=20write=20path=20=E2=80=94?= =?UTF-8?q?=20SharePrivilege=20load/save=20(#057=20Phase=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds declarative NFS-export reconciliation, the last/highest-risk SynoSharp write capability: - NfsRuleSpec / NfsExportSpec (+ NfsExports on SynologyDesiredState) - SynoNfsTool: load (read rules), SaveCommand (whole-list replace), and EnableServiceCommand, over `synowebapi SYNO.Core.FileServ.NFS.SharePrivilege`. security_flavor is encoded as the bool-flag OBJECT DSM expects (not an array). - Reconciler NFS planning: load -> diff -> save, dry-run by default, idempotent; Present=false clears the share's rules. - `synosharp exec ` raw passthrough (diagnostics; used to reverse-engineer the API). - 6 unit tests (SaveCommand argv, rule parsing, reconciler create/skip). Reverse-engineered + proven on a DSM 7.x Virtual DSM: create/skip/clear lifecycle yields /etc/exports lines identical to the production NAS. 24/24 tests pass. --- src/SynoSharp.Cli/Program.cs | 17 +- src/SynoSharp/Provisioning/Specs.cs | 45 +++++ .../Provisioning/SynologyReconciler.cs | 45 +++++ src/SynoSharp/Tools/SynoNfsTool.cs | 156 ++++++++++++++++++ tests/SynoSharp.Tests/SynoNfsTests.cs | 113 +++++++++++++ 5 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 src/SynoSharp/Tools/SynoNfsTool.cs create mode 100644 tests/SynoSharp.Tests/SynoNfsTests.cs diff --git a/src/SynoSharp.Cli/Program.cs b/src/SynoSharp.Cli/Program.cs index 06a2a41..78a6cf2 100644 --- a/src/SynoSharp.Cli/Program.cs +++ b/src/SynoSharp.Cli/Program.cs @@ -37,7 +37,7 @@ discover Dump a SynologySnapshot (DSM version, shares, users) as J return 0; } -if (command is "ssh-check" or "plan" or "apply") +if (command is "ssh-check" or "plan" or "apply" or "exec") { var sshOptions = SynologySshOptions.TryFromEnvironment(); if (sshOptions is null) @@ -64,6 +64,21 @@ discover Dump a SynologySnapshot (DSM version, shares, users) as J return id.Success && shares.Success ? 0 : 1; } + // exec — raw passthrough over the SSH-runner (sudo-to-root), for probing/diagnostics. + if (command == "exec") + { + if (args.Length < 2) + { + Console.Error.WriteLine("Usage: synosharp exec [args...]"); + return 2; + } + var res = await runner.RunAsync(SynologyCommand.Create(args[1], args.Skip(2).ToArray())); + Console.WriteLine($"exit {res.ExitCode}"); + if (!string.IsNullOrWhiteSpace(res.StandardOutput)) { Console.WriteLine("--- stdout ---"); Console.WriteLine(res.StandardOutput.TrimEnd()); } + if (!string.IsNullOrWhiteSpace(res.StandardError)) { Console.WriteLine("--- stderr ---"); Console.WriteLine(res.StandardError.TrimEnd()); } + return res.Success ? 0 : 1; + } + // plan / apply if (args.Length < 2) { diff --git a/src/SynoSharp/Provisioning/Specs.cs b/src/SynoSharp/Provisioning/Specs.cs index e4c0a48..974b754 100644 --- a/src/SynoSharp/Provisioning/Specs.cs +++ b/src/SynoSharp/Provisioning/Specs.cs @@ -41,10 +41,55 @@ public sealed record GroupSpec public bool Present { get; init; } = true; } +/// +/// One NFS export rule for a share — the load/save shape of +/// SYNO.Core.FileServ.NFS.SharePrivilege (DSM 7.x). Defaults match the homelab +/// model (BL-016): a CIDR client, read-write, all-users-squashed-to-admin, AUTH_SYS. +/// +public sealed record NfsRuleSpec +{ + /// Client host or network, e.g. 10.10.0.0/16 or a single IP. + public required string Client { get; init; } + + /// rw or ro. + public string Privilege { get; init; } = "rw"; + + /// + /// Squash mapping (root_squash in the API): no | admin | guest | + /// all_admin | all_guest. all_admin = map every user to admin + /// (the all_squash + anonuid/anongid=admin model). + /// + public string Squash { get; init; } = "all_admin"; + + public bool Async { get; init; } = true; + + /// Allow connections from non-privileged ports (NFS insecure). + public bool Insecure { get; init; } = true; + + /// Allow access to mounted subfolders (NFS crossmnt). + public bool Crossmnt { get; init; } = true; + + /// Security flavor: sys | krb5 | krb5i | krb5p. + public string Security { get; init; } = "sys"; +} + +/// +/// Desired NFS export rules for a share. DSM's save is a whole-list REPLACE, so +/// is the full intended rule set. false clears +/// all rules for the share. +/// +public sealed record NfsExportSpec +{ + public required string Share { get; init; } + public IReadOnlyList Rules { get; init; } = []; + public bool Present { get; init; } = true; +} + /// A bundle of desired DSM state — the input to the reconciler. public sealed record SynologyDesiredState { public IReadOnlyList Groups { get; init; } = []; public IReadOnlyList Users { get; init; } = []; public IReadOnlyList Shares { get; init; } = []; + public IReadOnlyList NfsExports { get; init; } = []; } diff --git a/src/SynoSharp/Provisioning/SynologyReconciler.cs b/src/SynoSharp/Provisioning/SynologyReconciler.cs index a964189..3d65a28 100644 --- a/src/SynoSharp/Provisioning/SynologyReconciler.cs +++ b/src/SynoSharp/Provisioning/SynologyReconciler.cs @@ -24,6 +24,7 @@ public sealed class SynologyReconciler private readonly SynoShareTool _shares; private readonly SynoUserTool _users; private readonly SynoGroupTool _groups; + private readonly SynoNfsTool _nfs; public SynologyReconciler(ISshRunner runner) { @@ -32,6 +33,7 @@ public SynologyReconciler(ISshRunner runner) _shares = new SynoShareTool(runner); _users = new SynoUserTool(runner); _groups = new SynoGroupTool(runner); + _nfs = new SynoNfsTool(runner); } /// Read live state, diff against , and return the plan. @@ -57,6 +59,11 @@ public async Task PlanAsync(SynologyDesiredState desired, Cancella { actions.Add(await PlanShareAsync(s, Contains(existingShares, s.Name), cancellationToken).ConfigureAwait(false)); } + // NFS exports last — they depend on the share existing (read-before-write per share). + foreach (var x in desired.NfsExports) + { + actions.Add(await PlanNfsAsync(x, cancellationToken).ConfigureAwait(false)); + } return new SynologyPlan { Actions = actions }; } @@ -190,4 +197,42 @@ private async Task PlanShareAsync(ShareSpec s, bool exists, Cance } return PlannedAction.Skip("share", s.Name, "in sync"); } + + private async Task PlanNfsAsync(NfsExportSpec x, CancellationToken ct) + { + IReadOnlyList current; + try + { + current = await _nfs.LoadAsync(x.Share, ct).ConfigureAwait(false); + } + catch (SynologyToolException) + { + // Don't abort the whole plan — surface it as a blocked skip (share missing / NFS off). + return PlannedAction.Skip("nfs-export", x.Share, "BLOCKED: cannot read NFS rules (share exists + NFS enabled?)"); + } + + var desired = x.Present ? x.Rules : []; + if (RulesEqual(current, desired)) + { + return PlannedAction.Skip("nfs-export", x.Share, x.Present ? "in sync" : "already empty"); + } + + // DSM `save` is a whole-list replace; Present=false → replace with an empty set (clear). + var cmd = SynoNfsTool.SaveCommand(x.Present ? x : x with { Rules = [] }); + if (current.Count == 0) + { + return PlannedAction.Create("nfs-export", x.Share, cmd, $"absent → set {desired.Count} rule(s)"); + } + if (desired.Count == 0) + { + return PlannedAction.Delete("nfs-export", x.Share, cmd, $"{current.Count} rule(s) → clear"); + } + return PlannedAction.Modify("nfs-export", x.Share, cmd, $"{current.Count} → {desired.Count} rule(s)"); + } + + // Order-insensitive value comparison (NfsRuleSpec is a record → structural equality). + private static bool RulesEqual(IReadOnlyList a, IReadOnlyList b) + => a.Count == b.Count + && a.OrderBy(r => r.Client, StringComparer.Ordinal) + .SequenceEqual(b.OrderBy(r => r.Client, StringComparer.Ordinal)); } diff --git a/src/SynoSharp/Tools/SynoNfsTool.cs b/src/SynoSharp/Tools/SynoNfsTool.cs new file mode 100644 index 0000000..02d0fab --- /dev/null +++ b/src/SynoSharp/Tools/SynoNfsTool.cs @@ -0,0 +1,156 @@ +using System.Text.Json; +using SynoSharp.Provisioning; +using SynoSharp.Ssh; + +namespace SynoSharp.Tools; + +/// +/// Typed wrapper over DSM's NFS export API (SYNO.Core.FileServ.NFS.SharePrivilege, +/// driven through synowebapi over SSH — ADR-0002, plan #057 Phase C). load +/// reads a share's rules; mutations are returned as s for the +/// reconciler to plan/apply, so nothing here mutates on its own. +/// +/// The rule shape (reverse-engineered on a DSM 7.x VDSM, 2026-06-15): each rule is +/// {client, privilege, root_squash, async, insecure, crossmnt, security_flavor:{sys, +/// kerberos, kerberos_integrity, kerberos_privacy}}. security_flavor is an OBJECT +/// of bool flags (not an array). save is a whole-list REPLACE for the share. +/// +/// +public sealed class SynoNfsTool +{ + private const string Api = "api=SYNO.Core.FileServ.NFS.SharePrivilege"; + private readonly ISshRunner _runner; + + public SynoNfsTool(ISshRunner runner) + { + ArgumentNullException.ThrowIfNull(runner); + _runner = runner; + } + + /// Read the share's current NFS rules via load (empty list if none). + public async Task> LoadAsync(string share, CancellationToken cancellationToken = default) + { + var cmd = SynologyCommand.Create("synowebapi", "--exec", Api, "method=load", "version=1", $"share_name={share}"); + var result = await _runner.RunAsync(cmd, cancellationToken).ConfigureAwait(false); + if (!result.Success) + { + throw new SynologyToolException($"synowebapi NFS load {share}", result); + } + return ParseRules(result.StandardOutput); + } + + /// + /// synowebapi … method=save share_name=<share> rule=<json> — replaces the + /// share's entire rule set (empty list clears it). + /// + public static SynologyCommand SaveCommand(NfsExportSpec spec) + { + ArgumentNullException.ThrowIfNull(spec); + var json = JsonSerializer.Serialize(spec.Rules.Select(ToWire).ToArray()); + return SynologyCommand.Create("synowebapi", "--exec", Api, "method=save", "version=1", + $"share_name={spec.Share}", $"rule={json}"); + } + + /// Enable the NFS service (v3 + v4) — a prerequisite for any export to apply. + public static SynologyCommand EnableServiceCommand() + => SynologyCommand.Create("synowebapi", "--exec", "api=SYNO.Core.FileServ.NFS", + "method=set", "version=1", "enable_nfs=true", "enable_nfs_v4=true"); + + // The on-wire rule object. Member names ARE the JSON keys (no naming policy), so + // `root_squash`/`security_flavor`/`kerberos_integrity` must match DSM verbatim; `@async` + // serialises as "async" (C# keyword). + private static object ToWire(NfsRuleSpec r) => new + { + client = r.Client, + privilege = r.Privilege, + root_squash = r.Squash, + @async = r.Async, + insecure = r.Insecure, + crossmnt = r.Crossmnt, + security_flavor = new + { + sys = r.Security == "sys", + kerberos = r.Security == "krb5", + kerberos_integrity = r.Security == "krb5i", + kerberos_privacy = r.Security == "krb5p", + }, + }; + + /// + /// Parse {data:{rule:[…]}} out of synowebapi's stdout (which may trail diagnostic + /// [Line …] noise) into s. + /// + public static IReadOnlyList ParseRules(string stdout) + { + var json = ExtractFirstJsonObject(stdout); + if (json is null) + { + return []; + } + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("data", out var data) || + !data.TryGetProperty("rule", out var rules) || + rules.ValueKind != JsonValueKind.Array) + { + return []; + } + + var list = new List(); + foreach (var e in rules.EnumerateArray()) + { + list.Add(new NfsRuleSpec + { + Client = Str(e, "client"), + Privilege = Str(e, "privilege", "rw"), + Squash = Str(e, "root_squash", "all_admin"), + Async = Bool(e, "async"), + Insecure = Bool(e, "insecure"), + Crossmnt = Bool(e, "crossmnt"), + Security = SecurityFromFlavor(e), + }); + } + return list; + } + + private static string SecurityFromFlavor(JsonElement rule) + { + if (!rule.TryGetProperty("security_flavor", out var sf) || sf.ValueKind != JsonValueKind.Object) + { + return "sys"; + } + if (Bool(sf, "kerberos_privacy")) return "krb5p"; + if (Bool(sf, "kerberos_integrity")) return "krb5i"; + if (Bool(sf, "kerberos")) return "krb5"; + return "sys"; + } + + private static string Str(JsonElement e, string name, string fallback = "") + => e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? (v.GetString() ?? fallback) : fallback; + + private static bool Bool(JsonElement e, string name) + => e.TryGetProperty(name, out var v) && (v.ValueKind == JsonValueKind.True || (v.ValueKind == JsonValueKind.False ? false : v.ValueKind == JsonValueKind.String && bool.TryParse(v.GetString(), out var b) && b)); + + /// Return the first balanced top-level {…} object in , or null. + private static string? ExtractFirstJsonObject(string s) + { + var start = s.IndexOf('{'); + if (start < 0) return null; + int depth = 0; + bool inStr = false, esc = false; + for (int i = start; i < s.Length; i++) + { + var c = s[i]; + if (inStr) + { + if (esc) esc = false; + else if (c == '\\') esc = true; + else if (c == '"') inStr = false; + continue; + } + if (c == '"') inStr = true; + else if (c == '{') depth++; + else if (c == '}' && --depth == 0) return s.Substring(start, i - start + 1); + } + return null; + } +} diff --git a/tests/SynoSharp.Tests/SynoNfsTests.cs b/tests/SynoSharp.Tests/SynoNfsTests.cs new file mode 100644 index 0000000..10cde6e --- /dev/null +++ b/tests/SynoSharp.Tests/SynoNfsTests.cs @@ -0,0 +1,113 @@ +using SynoSharp.Provisioning; +using SynoSharp.Ssh; +using SynoSharp.Tools; +using Xunit; + +namespace SynoSharp.Tests; + +public class SynoNfsTests +{ + [Fact] + public void SaveCommand_emits_the_expected_synowebapi_argv() + { + var spec = new NfsExportSpec + { + Share = "data", + Rules = [new NfsRuleSpec { Client = "10.10.0.0/16" }], // defaults: rw / all_admin / sys / async+insecure+crossmnt + }; + + var rendered = SynoNfsTool.SaveCommand(spec).Render(); + + Assert.Contains("api=SYNO.Core.FileServ.NFS.SharePrivilege", rendered); + Assert.Contains("method=save", rendered); + Assert.Contains("share_name=data", rendered); + // The on-wire rule object — exact DSM keys, security_flavor as an OBJECT (not array). + Assert.Contains("\"client\":\"10.10.0.0/16\"", rendered); + Assert.Contains("\"privilege\":\"rw\"", rendered); + Assert.Contains("\"root_squash\":\"all_admin\"", rendered); + Assert.Contains("\"async\":true", rendered); + Assert.Contains("\"security_flavor\":{\"sys\":true,\"kerberos\":false", rendered); + } + + [Fact] + public void SaveCommand_maps_security_flavor_to_the_right_flag() + { + var rendered = SynoNfsTool.SaveCommand(new NfsExportSpec + { + Share = "s", + Rules = [new NfsRuleSpec { Client = "1.2.3.4", Security = "krb5i" }], + }).Render(); + + Assert.Contains("\"kerberos_integrity\":true", rendered); + Assert.Contains("\"sys\":false", rendered); + } + + [Fact] + public void ParseRules_reads_the_canonical_load_output_ignoring_trailing_noise() + { + // Real synowebapi stdout: pretty JSON then diagnostic "[Line …]" noise. + const string stdout = """ + { + "data" : { "rule" : [ { + "async" : true, "client" : "10.10.0.0/16", "crossmnt" : true, + "insecure" : true, "privilege" : "rw", "root_squash" : "all_admin", + "security_flavor" : { "kerberos" : false, "kerberos_integrity" : false, "kerberos_privacy" : false, "sys" : true } + } ] }, + "success" : true + } + [Line 265] Not a json value: data + [Line 295] Exec WebAPI: api=SYNO.Core.FileServ.NFS.SharePrivilege + """; + + var r = Assert.Single(SynoNfsTool.ParseRules(stdout)); + Assert.Equal("10.10.0.0/16", r.Client); + Assert.Equal("rw", r.Privilege); + Assert.Equal("all_admin", r.Squash); + Assert.True(r.Async); + Assert.True(r.Insecure); + Assert.True(r.Crossmnt); + Assert.Equal("sys", r.Security); + } + + [Fact] + public void ParseRules_returns_empty_for_a_share_with_no_rules() + => Assert.Empty(SynoNfsTool.ParseRules("""{"data":{"rule":[]},"success":true}""")); + + [Fact] + public async Task Reconciler_creates_export_when_the_share_has_no_rules() + { + var runner = new NfsFakeRunner("""{"data":{"rule":[]},"success":true}"""); + var plan = await new SynologyReconciler(runner).PlanAsync(new SynologyDesiredState + { + NfsExports = [new NfsExportSpec { Share = "data", Rules = [new NfsRuleSpec { Client = "10.10.0.0/16" }] }], + }); + + var action = Assert.Single(plan.Actions); + Assert.Equal(ActionKind.Create, action.Kind); + Assert.Equal("nfs-export", action.ResourceType); + Assert.Contains("method=save", action.Command!.Render()); + } + + [Fact] + public async Task Reconciler_skips_when_the_rule_set_already_matches() + { + const string canned = """{"data":{"rule":[{"client":"10.10.0.0/16","privilege":"rw","root_squash":"all_admin","async":true,"insecure":true,"crossmnt":true,"security_flavor":{"sys":true,"kerberos":false,"kerberos_integrity":false,"kerberos_privacy":false}}]},"success":true}"""; + var plan = await new SynologyReconciler(new NfsFakeRunner(canned)).PlanAsync(new SynologyDesiredState + { + NfsExports = [new NfsExportSpec { Share = "data", Rules = [new NfsRuleSpec { Client = "10.10.0.0/16" }] }], + }); + + Assert.Equal(ActionKind.Skip, Assert.Single(plan.Actions).Kind); + } + + /// Minimal scripted runner: returns canned JSON for load, success for anything else. + private sealed class NfsFakeRunner(string loadJson) : ISshRunner + { + public Task RunAsync(SynologyCommand command, CancellationToken cancellationToken = default) + { + var args = string.Join(' ', command.Args); + var stdout = args.Contains("method=load") ? loadJson : ""; + return Task.FromResult(new SshCommandResult(0, stdout, "")); + } + } +}