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
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ this is a hand-written **read-API client** (now) + an **SSH-runner** for mutatio

| Project | What |
| --- | --- |
| `src/SynoSharp/` | The client — `SynologyApiClient` (Web-API read), `SynologyDiscovery` → `SynologySnapshot`, and `Ssh/` (the SSH-runner: `ISshRunner`/`SshRunner`, `SynologyCommand`). SemVer. |
| `src/SynoSharp.Cli/` | `synosharp` dotnet tool — `discover`, `ssh-check`. |
| `tests/SynoSharp.Tests/` | Unit + skippable live tests (Web-API + SSH). |
| `src/SynoSharp/` | The client — `SynologyApiClient` (Web-API read), `SynologyDiscovery` → `SynologySnapshot`, `Ssh/` (the SSH-runner), `Tools/` (typed `synoshare`/`synouser`/`synogroup` wrappers), `Provisioning/` (specs + reconciler). SemVer. |
| `src/SynoSharp.Cli/` | `synosharp` dotnet tool — `discover`, `ssh-check`, `plan`, `apply`. |
| `tests/SynoSharp.Tests/` | Unit (quoting + reconciler) + skippable live tests (Web-API + SSH). |

## Build / use

Expand Down Expand Up @@ -54,6 +54,22 @@ proves the full stack end-to-end (SSH login → sudo-to-root → on-box `syno*`)
(`ISshRunner`/`SshRunner` over SSH.NET) + structured `SynologyCommand` (shell-quoted
argv) are the transport for the write path.

**Next:** the first typed mutation — `EnsureShareAsync` with **read-before-write**
(diff the discover snapshot, emit only the needed command) and **dry-run by default**.
NFS exports come last (highest-risk; prove on Virtual DSM, not the live box).
**Write path (Phase A) — reconciler for shares/users/groups, dry-run by default.**
Desired-state specs (`ShareSpec`/`UserSpec`/`GroupSpec`) are diffed against live
state by `SynologyReconciler` → a `SynologyPlan` of create/delete/skip actions; each
carries the exact `synoshare`/`synouser`/`synogroup` command. `ApplyAsync(apply:false)`
is a dry-run (the default). **Verified against the live NAS (2026-05-31):** `plan`
correctly skipped existing resources, planned creates for new ones, and blocked a
passwordless user create — **zero mutation** (reads only).

```bash
synosharp plan spec.json # diff vs live → dry-run plan (read-only)
synosharp apply spec.json # still a dry-run…
synosharp apply spec.json --confirm # …only this mutates
```

The reconciler does **existence reconciliation** (create-if-missing /
delete-if-`present:false` / skip-if-present) and **never prunes** unmanaged
resources. **Next:** field-level drift (desc/ACLs), then NFS exports via
`synowebapi` — last, highest-risk, prove on Virtual DSM (needs an x86/KVM host).
See the [write-path plan](https://github.com/chrison-dev/Homelab/blob/main/docs/plans/057-synosharp-write-path.md).
95 changes: 74 additions & 21 deletions src/SynoSharp.Cli/Program.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
using System.Text.Json;
using SynoSharp;
using SynoSharp.Provisioning;
using SynoSharp.Ssh;

// synosharp — a thin CLI over the SynoSharp library.
//
// Commands: discover (Web-API read), ssh-check (prove the SSH-runner transport)
// Config (env): SYNOLOGY_BASE_URL (e.g. https://nas:5001), SYNOLOGY_USER,
// SYNOLOGY_PASSWORD, SYNOLOGY_VERIFY_TLS (optional, 'false'),
// Commands:
// discover Web-API read → SynologySnapshot (JSON)
// ssh-check prove the SSH-runner transport (login + sudo + read)
// plan <spec.json> diff a desired-state spec vs live → dry-run plan
// apply <spec.json> [--confirm] apply the plan (dry-run unless --confirm)
//
// Config (env): SYNOLOGY_BASE_URL, SYNOLOGY_USER, SYNOLOGY_PASSWORD,
// SYNOLOGY_VERIFY_TLS (optional 'false'),
// SYNOLOGY_SSH_HOST/PORT/KEY (optional; host falls back to BASE_URL)

var command = args.Length > 0 ? args[0].ToLowerInvariant() : "help";
Expand All @@ -18,17 +24,20 @@
synosharp — Synology DSM client

Usage: synosharp <command>
discover Dump a SynologySnapshot (DSM version, shares, users) as JSON
ssh-check Prove the SSH-runner: SSH login + sudo-to-root + a read-only syno* read
discover Dump a SynologySnapshot (DSM version, shares, users) as JSON
ssh-check Prove the SSH-runner: login + sudo-to-root + read-only syno* read
plan <spec.json> Diff a desired-state spec against the live box (dry-run)
apply <spec.json> Apply the plan — dry-run unless --confirm is given
[--confirm]

Config (env): SYNOLOGY_BASE_URL (e.g. https://nas:5001), SYNOLOGY_USER,
SYNOLOGY_PASSWORD, SYNOLOGY_VERIFY_TLS (optional, 'false'),
Config (env): SYNOLOGY_BASE_URL, SYNOLOGY_USER, SYNOLOGY_PASSWORD,
SYNOLOGY_VERIFY_TLS (optional 'false'),
SYNOLOGY_SSH_HOST/PORT/KEY (optional; host falls back to BASE_URL)
""");
return 0;
}

if (command == "ssh-check")
if (command is "ssh-check" or "plan" or "apply")
{
var sshOptions = SynologySshOptions.TryFromEnvironment();
if (sshOptions is null)
Expand All @@ -39,24 +48,68 @@ discover Dump a SynologySnapshot (DSM version, shares, users) as JSON

using var runner = new SshRunner(sshOptions);

// 1. Transport, no root — proves SSH login works.
var id = await runner.RunAsync(new SynologyCommand { Executable = "id", RequiresRoot = false });
Console.WriteLine($"id → exit {id.ExitCode}: {id.StandardOutput.Trim()}");
if (command == "ssh-check")
{
// 1. Transport, no root — proves SSH login works.
var id = await runner.RunAsync(new SynologyCommand { Executable = "id", RequiresRoot = false });
Console.WriteLine($"id → exit {id.ExitCode}: {id.StandardOutput.Trim()}");

// 2. sudo-to-root + a real read-only syno* read — the full risky stack, ZERO mutation.
var shares = await runner.RunAsync(SynologyCommand.Create("synoshare", "--enum", "ALL"));
Console.WriteLine($"synoshare ALL → exit {shares.ExitCode}");
if (!string.IsNullOrWhiteSpace(shares.StandardOutput))
{
Console.WriteLine(shares.StandardOutput.TrimEnd());
}
return id.Success && shares.Success ? 0 : 1;
}

// plan / apply
if (args.Length < 2)
{
Console.Error.WriteLine($"Usage: synosharp {command} <spec.json>{(command == "apply" ? " [--confirm]" : "")}");
return 2;
}

// 2. sudo-to-root + a real read-only syno* read — proves the full risky stack
// (sudo via stdin → root → on-box CLI) with ZERO mutation.
var shares = await runner.RunAsync(SynologyCommand.Create("synoshare", "--enum", "ALL"));
Console.WriteLine($"synoshare ALL → exit {shares.ExitCode}");
if (!string.IsNullOrWhiteSpace(shares.StandardOutput))
var specPath = args[1];
if (!File.Exists(specPath))
{
Console.WriteLine(shares.StandardOutput.TrimEnd());
Console.Error.WriteLine($"Spec file not found: {specPath}");
return 2;
}
if (!string.IsNullOrWhiteSpace(shares.StandardError))

var desired = JsonSerializer.Deserialize<SynologyDesiredState>(
await File.ReadAllTextAsync(specPath),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (desired is null)
{
Console.Error.WriteLine(shares.StandardError.TrimEnd());
Console.Error.WriteLine("Spec file did not parse to a desired state.");
return 2;
}

return id.Success && shares.Success ? 0 : 1;
var reconciler = new SynologyReconciler(runner);
var plan = await reconciler.PlanAsync(desired);
Console.WriteLine(plan.Render());

if (command == "plan")
{
return 0;
}

var confirm = args.Contains("--confirm");
if (!confirm)
{
Console.WriteLine("\n(dry-run — pass --confirm to apply)");
return 0;
}

Console.WriteLine($"\nApplying {plan.Mutations.Count()} change(s)…");
var result = await reconciler.ApplyAsync(plan, apply: true);
foreach (var outcome in result.Outcomes.Where(o => o.Action.Kind != ActionKind.Skip))
{
Console.WriteLine($" {outcome.Action.ResourceType} {outcome.Action.Name}: {outcome.Message}");
}
return result.AllSucceeded ? 0 : 1;
}

var options = SynologyOptions.TryFromEnvironment();
Expand All @@ -80,6 +133,6 @@ discover Dump a SynologySnapshot (DSM version, shares, users) as JSON
return 0;

default:
Console.Error.WriteLine($"Unknown command '{command}'. Try: discover, ssh-check");
Console.Error.WriteLine($"Unknown command '{command}'. Try: discover, ssh-check, plan, apply");
return 1;
}
42 changes: 42 additions & 0 deletions src/SynoSharp/Provisioning/Specs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace SynoSharp.Provisioning;

/// <summary>Desired state for a shared folder. <see cref="Present"/> false = ensure absent.</summary>
public sealed record ShareSpec
{
public required string Name { get; init; }
public string Description { get; init; } = "";

/// <summary>Full on-box path, e.g. <c>/volume1/MyShare</c>.</summary>
public required string Path { get; init; }

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

/// <summary>Desired state for a local user. <see cref="Present"/> false = ensure absent.</summary>
public sealed record UserSpec
{
public required string Name { get; init; }
public string FullName { get; init; } = "";
public string? Email { get; init; }

/// <summary>Used only when creating the user (never read back); required for a create.</summary>
public string? Password { get; init; }

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

/// <summary>Desired state for a local group. <see cref="Present"/> false = ensure absent.</summary>
public sealed record GroupSpec
{
public required string Name { get; init; }
public string Description { 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; } = [];
}
81 changes: 81 additions & 0 deletions src/SynoSharp/Provisioning/SynologyPlan.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Text;
using SynoSharp.Ssh;

namespace SynoSharp.Provisioning;

public enum ActionKind
{
Create,
Delete,
Skip,
}

/// <summary>
/// One reconciled action. <see cref="Command"/> is the on-box command to run for a
/// mutation (null for <see cref="ActionKind.Skip"/>), and <see cref="Reason"/>
/// explains the decision (for dry-run output).
/// </summary>
public sealed record PlannedAction
{
public required ActionKind Kind { get; init; }

/// <summary><c>group</c> / <c>user</c> / <c>share</c>.</summary>
public required string ResourceType { get; init; }

public required string Name { get; init; }
public required string Reason { get; init; }
public SynologyCommand? Command { get; init; }

public static PlannedAction Create(string type, string name, SynologyCommand command, string reason)
=> new() { Kind = ActionKind.Create, ResourceType = type, Name = name, Command = command, Reason = reason };

public static PlannedAction Delete(string type, string name, SynologyCommand command, string reason)
=> new() { Kind = ActionKind.Delete, ResourceType = type, Name = name, Command = command, Reason = reason };

public static PlannedAction Skip(string type, string name, string reason)
=> new() { Kind = ActionKind.Skip, ResourceType = type, Name = name, Reason = reason };
}

/// <summary>The reconciled plan — what would change. Render it for a dry-run.</summary>
public sealed record SynologyPlan
{
public required IReadOnlyList<PlannedAction> Actions { get; init; }

/// <summary>Just the actions that would mutate the box.</summary>
public IEnumerable<PlannedAction> Mutations => Actions.Where(a => a.Kind != ActionKind.Skip);

public bool HasChanges => Mutations.Any();

public string Render()
{
var sb = new StringBuilder();
foreach (var a in Actions)
{
var marker = a.Kind switch
{
ActionKind.Create => "+ create",
ActionKind.Delete => "- delete",
_ => "= skip ",
};
sb.Append(marker).Append(' ').Append(a.ResourceType).Append(' ').Append(a.Name)
.Append(" (").Append(a.Reason).Append(')');
if (a.Command is not null)
{
sb.Append("\n $ ").Append(a.Command.Render());
}
sb.Append('\n');
}
var changes = Mutations.Count();
sb.Append(changes == 0 ? "No changes." : $"{changes} change(s) to apply.");
return sb.ToString();
}
}

/// <summary>Outcome of executing (or dry-running) a single planned action.</summary>
public sealed record ActionOutcome(PlannedAction Action, bool Applied, string Message);

public sealed record SynologyApplyResult
{
public required IReadOnlyList<ActionOutcome> Outcomes { get; init; }
public bool AllSucceeded => Outcomes.All(o => o.Applied || o.Action.Kind == ActionKind.Skip);
}
Loading
Loading