diff --git a/README.md b/README.md index 49df3f0..437bc48 100644 --- a/README.md +++ b/README.md @@ -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`. SemVer. | -| `src/SynoSharp.Cli/` | `synosharp` dotnet tool — `discover`. | -| `tests/SynoSharp.Tests/` | Unit + skippable live test. | +| `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). | ## Build / use @@ -28,8 +28,16 @@ this is a hand-written **read-API client** (now) + an **SSH-runner** for mutatio dotnet build && dotnet test export SYNOLOGY_BASE_URL=https://nas:5001 SYNOLOGY_USER=… SYNOLOGY_PASSWORD=… SYNOLOGY_VERIFY_TLS=false synosharp discover # JSON snapshot: model, serial, DSM version, shares, users +synosharp ssh-check # prove the SSH-runner: login + sudo-to-root + read-only `synoshare --enum` ``` +The SSH-runner reuses `SYNOLOGY_USER`/`SYNOLOGY_PASSWORD` (the account also supplies +the `sudo` password — `syno*` need root) and derives the host from `SYNOLOGY_BASE_URL`; +override with `SYNOLOGY_SSH_HOST`/`SYNOLOGY_SSH_PORT`/`SYNOLOGY_SSH_KEY`. DSM 7 disables +direct root SSH, so the runner logs in as an admin user and `sudo -S` (password over +stdin, never in the command line), running tools through `env PATH=/usr/syno/sbin:…` +since sudo's `secure_path` excludes the syno dirs. + Packages publish to GitHub Packages (chrison-dev) like the siblings: prerelease on push to `main`, stable on `v*` tag. @@ -38,5 +46,14 @@ on push to `main`, stable on `v*` tag. **Read/discover — verified against the live NAS (2026-05-31, DS1813+ / DSM 7.1.1-42962).** `discover` returns model, serial, DSM version, share names and user names (`SYNO.Core.System` / `Share` / `User`). The `SYNO.Core.*` reads are -defensive (degrade to empty on shape mismatch). **Next:** the SSH-runner for the -write path (shares/NFS/users via on-box `syno*` + `synowebapi`). +defensive (degrade to empty on shape mismatch). + +**SSH-runner transport — verified against the live NAS (2026-05-31).** `ssh-check` +proves the full stack end-to-end (SSH login → sudo-to-root → on-box `syno*`): +`synoshare --enum ALL` lists the live shares with **zero mutation**. The runner +(`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). diff --git a/src/SynoSharp.Cli/Program.cs b/src/SynoSharp.Cli/Program.cs index 471e4ef..41d2667 100644 --- a/src/SynoSharp.Cli/Program.cs +++ b/src/SynoSharp.Cli/Program.cs @@ -1,11 +1,13 @@ using System.Text.Json; using SynoSharp; +using SynoSharp.Ssh; -// synosharp — a thin read-only CLI over the SynoSharp library. +// synosharp — a thin CLI over the SynoSharp library. // -// Commands: discover +// 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') +// 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"; @@ -13,17 +15,50 @@ { Console.WriteLine( """ - synosharp — read-only Synology DSM client + synosharp — Synology DSM client Usage: synosharp - discover Dump a SynologySnapshot (DSM version, shares, users) as JSON + 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 Config (env): SYNOLOGY_BASE_URL (e.g. https://nas:5001), SYNOLOGY_USER, - SYNOLOGY_PASSWORD, SYNOLOGY_VERIFY_TLS (optional, 'false') + 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") +{ + var sshOptions = SynologySshOptions.TryFromEnvironment(); + if (sshOptions is null) + { + Console.Error.WriteLine("Missing SSH config. Set SYNOLOGY_SSH_HOST (or SYNOLOGY_BASE_URL) and SYNOLOGY_USER."); + return 2; + } + + 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()}"); + + // 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)) + { + Console.WriteLine(shares.StandardOutput.TrimEnd()); + } + if (!string.IsNullOrWhiteSpace(shares.StandardError)) + { + Console.Error.WriteLine(shares.StandardError.TrimEnd()); + } + + return id.Success && shares.Success ? 0 : 1; +} + var options = SynologyOptions.TryFromEnvironment(); if (options is null) { @@ -45,6 +80,6 @@ discover Dump a SynologySnapshot (DSM version, shares, users) as JSON return 0; default: - Console.Error.WriteLine($"Unknown command '{command}'. Try: discover"); + Console.Error.WriteLine($"Unknown command '{command}'. Try: discover, ssh-check"); return 1; } diff --git a/src/SynoSharp/Ssh/ISshRunner.cs b/src/SynoSharp/Ssh/ISshRunner.cs new file mode 100644 index 0000000..a42a69d --- /dev/null +++ b/src/SynoSharp/Ssh/ISshRunner.cs @@ -0,0 +1,12 @@ +namespace SynoSharp.Ssh; + +/// +/// Executes structured on-box commands over SSH — the mutation transport for +/// SynoSharp (ADR-0002: syno* CLI + synowebapi --exec, sudo-to-root). +/// Typed Ensure* operations (later) build on this; this interface is the +/// raw transport so it can be faked in tests. +/// +public interface ISshRunner +{ + Task RunAsync(SynologyCommand command, CancellationToken cancellationToken = default); +} diff --git a/src/SynoSharp/Ssh/SshCommandResult.cs b/src/SynoSharp/Ssh/SshCommandResult.cs new file mode 100644 index 0000000..7b5b5bc --- /dev/null +++ b/src/SynoSharp/Ssh/SshCommandResult.cs @@ -0,0 +1,7 @@ +namespace SynoSharp.Ssh; + +/// Result of running a over SSH. +public sealed record SshCommandResult(int ExitCode, string StandardOutput, string StandardError) +{ + public bool Success => ExitCode == 0; +} diff --git a/src/SynoSharp/Ssh/SshRunner.cs b/src/SynoSharp/Ssh/SshRunner.cs new file mode 100644 index 0000000..2ce3440 --- /dev/null +++ b/src/SynoSharp/Ssh/SshRunner.cs @@ -0,0 +1,97 @@ +using System.Text; +using Renci.SshNet; + +namespace SynoSharp.Ssh; + +/// +/// SSH.NET-backed . Connects lazily and, for commands that +/// , runs them under sudo -S, +/// feeding the password over stdin — so the secret never appears in the +/// command string, the process list, or a dry-run render. +/// +public sealed class SshRunner : ISshRunner, IDisposable +{ + // DSM keeps the syno* CLIs under /usr/syno/{sbin,bin}, which sudo's secure_path + // excludes — so root commands are run through `env` with this PATH prepended. + private const string DsmToolPath = "/usr/syno/sbin:/usr/syno/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + + private readonly SynologySshOptions _options; + private readonly SshClient _client; + + public SshRunner(SynologySshOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _options = options; + _client = BuildClient(options); + + if (options.AcceptAnyHostKey) + { + _client.HostKeyReceived += (_, e) => e.CanTrust = true; + } + } + + private static SshClient BuildClient(SynologySshOptions o) + { + if (!string.IsNullOrEmpty(o.PrivateKeyPath)) + { + var keyFile = new PrivateKeyFile(o.PrivateKeyPath); + var auth = new PrivateKeyAuthenticationMethod(o.Username, keyFile); + return new SshClient(new ConnectionInfo(o.Host, o.Port, o.Username, auth)); + } + + if (string.IsNullOrEmpty(o.Password)) + { + throw new InvalidOperationException( + "SynologySshOptions needs a Password or PrivateKeyPath to connect."); + } + + var pwAuth = new PasswordAuthenticationMethod(o.Username, o.Password); + return new SshClient(new ConnectionInfo(o.Host, o.Port, o.Username, pwAuth)); + } + + public async Task RunAsync(SynologyCommand command, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + + if (!_client.IsConnected) + { + await _client.ConnectAsync(cancellationToken).ConfigureAwait(false); + } + + var sudo = command.RequiresRoot; + // -S: read the password from stdin; -p '': suppress the prompt so it never + // pollutes stdout. The password is written to stdin below, not interpolated. + // `/usr/bin/env PATH=…` makes the syno* tools resolvable under sudo's secure_path. + var text = sudo + ? $"sudo -S -p '' /usr/bin/env PATH={DsmToolPath} {command.Render()}" + : command.Render(); + + using var cmd = _client.CreateCommand(text); + + if (sudo) + { + if (string.IsNullOrEmpty(_options.Password)) + { + throw new InvalidOperationException( + "A root command requires a Password for sudo, but none was provided."); + } + + // The input stream is only valid once the channel is open, i.e. after + // execution has started — so kick off ExecuteAsync first, then write. + var exec = cmd.ExecuteAsync(cancellationToken); + using (var input = cmd.CreateInputStream()) + { + await input.WriteAsync(Encoding.UTF8.GetBytes(_options.Password + "\n"), cancellationToken).ConfigureAwait(false); + } + await exec.ConfigureAwait(false); + } + else + { + await cmd.ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + + return new SshCommandResult(cmd.ExitStatus ?? -1, cmd.Result ?? string.Empty, cmd.Error ?? string.Empty); + } + + public void Dispose() => _client.Dispose(); +} diff --git a/src/SynoSharp/Ssh/SynologyCommand.cs b/src/SynoSharp/Ssh/SynologyCommand.cs new file mode 100644 index 0000000..52c676a --- /dev/null +++ b/src/SynoSharp/Ssh/SynologyCommand.cs @@ -0,0 +1,39 @@ +namespace SynoSharp.Ssh; + +/// +/// A structured on-box command (executable + argv) for the SSH-runner. Inputs go +/// in as discrete argv elements and are shell-quoted only at render time, so +/// quoting/injection on the root shell isn't a footgun (ADR-0002). Most syno* +/// CLIs require root; DSM 7 has no direct root SSH, so the runner sudo's by default. +/// +public sealed record SynologyCommand +{ + public required string Executable { get; init; } + + public IReadOnlyList Args { get; init; } = []; + + /// + /// The syno* CLIs generally require root. DSM 7 disables direct root + /// SSH, so the runner runs this under sudo when true (the default). + /// + public bool RequiresRoot { get; init; } = true; + + public static SynologyCommand Create(string executable, params string[] args) + => new() { Executable = executable, Args = args }; + + /// Single-line, shell-quoted rendering — for dry-run display and execution. + public string Render() => string.Join(' ', new[] { Executable }.Concat(Args).Select(Quote)); + + /// + /// Shell-quote a token: bare if it's a safe "word" char set, else single-quoted + /// with embedded single-quotes escaped (''\''). + /// + private static string Quote(string s) + { + if (s.Length > 0 && s.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '/' or '.' or '=' or ':' or ',')) + { + return s; + } + return "'" + s.Replace("'", "'\\''") + "'"; + } +} diff --git a/src/SynoSharp/Ssh/SynologySshOptions.cs b/src/SynoSharp/Ssh/SynologySshOptions.cs new file mode 100644 index 0000000..f0c2dfb --- /dev/null +++ b/src/SynoSharp/Ssh/SynologySshOptions.cs @@ -0,0 +1,63 @@ +namespace SynoSharp.Ssh; + +/// +/// Connection options for the DSM SSH-runner. Distinct from +/// (the HTTP Web-API read client): SSH is a separate transport/port and may use a +/// key. The login account also supplies the sudo password (same account), +/// since syno* commands need root. +/// +public sealed record SynologySshOptions +{ + public required string Host { get; init; } + + public int Port { get; init; } = 22; + + public required string Username { get; init; } + + /// Login + sudo password. Optional if is set — but sudo still needs it. + public string? Password { get; init; } + + public string? PrivateKeyPath { get; init; } + + /// + /// Accept any SSH host key — the homelab pragmatic default for a self-managed + /// box (mirrors VerifyTls=false on the Web-API client). + /// + public bool AcceptAnyHostKey { get; init; } = true; + + /// + /// Build from env: SYNOLOGY_SSH_HOST (falls back to the host of + /// SYNOLOGY_BASE_URL), SYNOLOGY_SSH_PORT (default 22), + /// SYNOLOGY_USER, SYNOLOGY_PASSWORD, SYNOLOGY_SSH_KEY. + /// Null if host or user can't be resolved. + /// + public static SynologySshOptions? TryFromEnvironment() + { + var host = Environment.GetEnvironmentVariable("SYNOLOGY_SSH_HOST"); + if (string.IsNullOrEmpty(host)) + { + var baseUrl = Environment.GetEnvironmentVariable("SYNOLOGY_BASE_URL"); + if (!string.IsNullOrEmpty(baseUrl) && Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri)) + { + host = uri.Host; + } + } + + var user = Environment.GetEnvironmentVariable("SYNOLOGY_USER"); + if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(user)) + { + return null; + } + + var port = int.TryParse(Environment.GetEnvironmentVariable("SYNOLOGY_SSH_PORT"), out var p) ? p : 22; + + return new SynologySshOptions + { + Host = host, + Port = port, + Username = user, + Password = Environment.GetEnvironmentVariable("SYNOLOGY_PASSWORD"), + PrivateKeyPath = Environment.GetEnvironmentVariable("SYNOLOGY_SSH_KEY"), + }; + } +} diff --git a/src/SynoSharp/SynoSharp.csproj b/src/SynoSharp/SynoSharp.csproj index 0f86993..d3a50e7 100644 --- a/src/SynoSharp/SynoSharp.csproj +++ b/src/SynoSharp/SynoSharp.csproj @@ -7,7 +7,13 @@ 0.1.0 - C# client for Synology DSM — Web-API read/discover + (later) SSH-runner for mutations. See ADR-0002. + C# client for Synology DSM — Web-API read/discover + SSH-runner for mutations. See ADR-0002. + + + + + diff --git a/tests/SynoSharp.Tests/SshRunnerTests.cs b/tests/SynoSharp.Tests/SshRunnerTests.cs new file mode 100644 index 0000000..efb580b --- /dev/null +++ b/tests/SynoSharp.Tests/SshRunnerTests.cs @@ -0,0 +1,57 @@ +using SynoSharp.Ssh; +using Xunit; + +namespace SynoSharp.Tests; + +public class SynologyCommandTests +{ + [Fact] + public void Render_leaves_safe_word_tokens_bare() + { + var cmd = SynologyCommand.Create("synoshare", "--enum", "ALL"); + Assert.Equal("synoshare --enum ALL", cmd.Render()); + } + + [Fact] + public void Render_single_quotes_tokens_with_spaces() + { + var cmd = SynologyCommand.Create("synoshare", "--add", "My Share"); + Assert.Equal("synoshare --add 'My Share'", cmd.Render()); + } + + [Fact] + public void Render_escapes_embedded_single_quotes() + { + // A share name with an apostrophe must not break out of its quoting. + var cmd = SynologyCommand.Create("synoshare", "--add", "Chris's Files"); + Assert.Equal("synoshare --add 'Chris'\\''s Files'", cmd.Render()); + } + + [Fact] + public void RequiresRoot_defaults_to_true() + { + Assert.True(SynologyCommand.Create("synouser").RequiresRoot); + } +} + +/// +/// Read-only SSH-runner integration test against the live NAS. Runs only when the +/// SSH env is set; otherwise skips. Asserts the non-root transport (id) — +/// proving SSH login works without depending on sudo being configured. +/// +public class SshRunnerLiveTests +{ + [SkippableFact] + public async Task Id_returns_a_uid_over_ssh() + { + var options = SynologySshOptions.TryFromEnvironment(); + Skip.If(options is null || string.IsNullOrEmpty(options.Password), + "No SYNOLOGY_SSH/USER/PASSWORD env — skipping live SSH-runner test."); + + using var runner = new SshRunner(options!); + var result = await runner.RunAsync(new SynologyCommand { Executable = "id", RequiresRoot = false }); + + Assert.True(result.Success, $"`id` failed (exit {result.ExitCode}): {result.StandardError}"); + Assert.Contains("uid=", result.StandardOutput); + } +}