Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/SynoSharp.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 <executable> [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)
{
Expand Down
45 changes: 45 additions & 0 deletions src/SynoSharp/Provisioning/Specs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,55 @@ public sealed record GroupSpec
public bool Present { get; init; } = true;
}

/// <summary>
/// One NFS export rule for a share — the load/save shape of
/// <c>SYNO.Core.FileServ.NFS.SharePrivilege</c> (DSM 7.x). Defaults match the homelab
/// model (BL-016): a CIDR client, read-write, all-users-squashed-to-admin, AUTH_SYS.
/// </summary>
public sealed record NfsRuleSpec
{
/// <summary>Client host or network, e.g. <c>10.10.0.0/16</c> or a single IP.</summary>
public required string Client { get; init; }

/// <summary><c>rw</c> or <c>ro</c>.</summary>
public string Privilege { get; init; } = "rw";

/// <summary>
/// Squash mapping (<c>root_squash</c> in the API): <c>no</c> | <c>admin</c> | <c>guest</c> |
/// <c>all_admin</c> | <c>all_guest</c>. <c>all_admin</c> = map every user to admin
/// (the <c>all_squash</c> + anonuid/anongid=admin model).
/// </summary>
public string Squash { get; init; } = "all_admin";

public bool Async { get; init; } = true;

/// <summary>Allow connections from non-privileged ports (NFS <c>insecure</c>).</summary>
public bool Insecure { get; init; } = true;

/// <summary>Allow access to mounted subfolders (NFS <c>crossmnt</c>).</summary>
public bool Crossmnt { get; init; } = true;

/// <summary>Security flavor: <c>sys</c> | <c>krb5</c> | <c>krb5i</c> | <c>krb5p</c>.</summary>
public string Security { get; init; } = "sys";
}

/// <summary>
/// Desired NFS export rules for a share. DSM's <c>save</c> is a whole-list REPLACE, so
/// <see cref="Rules"/> is the full intended rule set. <see cref="Present"/> false clears
/// all rules for the share.
/// </summary>
public sealed record NfsExportSpec
{
public required string Share { get; init; }
public IReadOnlyList<NfsRuleSpec> Rules { get; init; } = [];
public bool Present { get; init; } = true;
}

/// <summary>A bundle of desired DSM state — the input to the reconciler.</summary>
public sealed record SynologyDesiredState
{
public IReadOnlyList<GroupSpec> Groups { get; init; } = [];
public IReadOnlyList<UserSpec> Users { get; init; } = [];
public IReadOnlyList<ShareSpec> Shares { get; init; } = [];
public IReadOnlyList<NfsExportSpec> NfsExports { get; init; } = [];
}
45 changes: 45 additions & 0 deletions src/SynoSharp/Provisioning/SynologyReconciler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -32,6 +33,7 @@ public SynologyReconciler(ISshRunner runner)
_shares = new SynoShareTool(runner);
_users = new SynoUserTool(runner);
_groups = new SynoGroupTool(runner);
_nfs = new SynoNfsTool(runner);
}

/// <summary>Read live state, diff against <paramref name="desired"/>, and return the plan.</summary>
Expand All @@ -57,6 +59,11 @@ public async Task<SynologyPlan> 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 };
}
Expand Down Expand Up @@ -190,4 +197,42 @@ private async Task<PlannedAction> PlanShareAsync(ShareSpec s, bool exists, Cance
}
return PlannedAction.Skip("share", s.Name, "in sync");
}

private async Task<PlannedAction> PlanNfsAsync(NfsExportSpec x, CancellationToken ct)
{
IReadOnlyList<NfsRuleSpec> 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<NfsRuleSpec> a, IReadOnlyList<NfsRuleSpec> b)
=> a.Count == b.Count
&& a.OrderBy(r => r.Client, StringComparer.Ordinal)
.SequenceEqual(b.OrderBy(r => r.Client, StringComparer.Ordinal));
}
156 changes: 156 additions & 0 deletions src/SynoSharp/Tools/SynoNfsTool.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
using System.Text.Json;
using SynoSharp.Provisioning;
using SynoSharp.Ssh;

namespace SynoSharp.Tools;

/// <summary>
/// Typed wrapper over DSM's NFS export API (<c>SYNO.Core.FileServ.NFS.SharePrivilege</c>,
/// driven through <c>synowebapi</c> over SSH — ADR-0002, plan #057 Phase C). <c>load</c>
/// reads a share's rules; mutations are returned as <see cref="SynologyCommand"/>s for the
/// reconciler to plan/apply, so nothing here mutates on its own.
/// <para>
/// The rule shape (reverse-engineered on a DSM 7.x VDSM, 2026-06-15): each rule is
/// <c>{client, privilege, root_squash, async, insecure, crossmnt, security_flavor:{sys,
/// kerberos, kerberos_integrity, kerberos_privacy}}</c>. <c>security_flavor</c> is an OBJECT
/// of bool flags (not an array). <c>save</c> is a whole-list REPLACE for the share.
/// </para>
/// </summary>
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;
}

/// <summary>Read the share's current NFS rules via <c>load</c> (empty list if none).</summary>
public async Task<IReadOnlyList<NfsRuleSpec>> 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);
}

/// <summary>
/// <c>synowebapi … method=save share_name=&lt;share&gt; rule=&lt;json&gt;</c> — replaces the
/// share's entire rule set (empty list clears it).
/// </summary>
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}");
}

/// <summary>Enable the NFS service (v3 + v4) — a prerequisite for any export to apply.</summary>
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",
},
};

/// <summary>
/// Parse <c>{data:{rule:[…]}}</c> out of synowebapi's stdout (which may trail diagnostic
/// <c>[Line …]</c> noise) into <see cref="NfsRuleSpec"/>s.
/// </summary>
public static IReadOnlyList<NfsRuleSpec> 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<NfsRuleSpec>();
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));

/// <summary>Return the first balanced top-level <c>{…}</c> object in <paramref name="s"/>, or null.</summary>
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;
}
}
Loading
Loading