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
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,26 @@ 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

```bash
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.

Expand All @@ -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).
49 changes: 42 additions & 7 deletions src/SynoSharp.Cli/Program.cs
Original file line number Diff line number Diff line change
@@ -1,29 +1,64 @@
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";

if (command is "help" or "-h" or "--help")
{
Console.WriteLine(
"""
synosharp — read-only Synology DSM client
synosharp — Synology DSM client

Usage: synosharp <command>
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)
{
Expand All @@ -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;
}
12 changes: 12 additions & 0 deletions src/SynoSharp/Ssh/ISshRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace SynoSharp.Ssh;

/// <summary>
/// Executes structured on-box commands over SSH — the mutation transport for
/// SynoSharp (ADR-0002: <c>syno*</c> CLI + <c>synowebapi --exec</c>, sudo-to-root).
/// Typed <c>Ensure*</c> operations (later) build on this; this interface is the
/// raw transport so it can be faked in tests.
/// </summary>
public interface ISshRunner
{
Task<SshCommandResult> RunAsync(SynologyCommand command, CancellationToken cancellationToken = default);
}
7 changes: 7 additions & 0 deletions src/SynoSharp/Ssh/SshCommandResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace SynoSharp.Ssh;

/// <summary>Result of running a <see cref="SynologyCommand"/> over SSH.</summary>
public sealed record SshCommandResult(int ExitCode, string StandardOutput, string StandardError)
{
public bool Success => ExitCode == 0;
}
97 changes: 97 additions & 0 deletions src/SynoSharp/Ssh/SshRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System.Text;
using Renci.SshNet;

namespace SynoSharp.Ssh;

/// <summary>
/// SSH.NET-backed <see cref="ISshRunner"/>. Connects lazily and, for commands that
/// <see cref="SynologyCommand.RequiresRoot"/>, runs them under <c>sudo -S</c>,
/// feeding the password over <b>stdin</b> — so the secret never appears in the
/// command string, the process list, or a dry-run render.
/// </summary>
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<SshCommandResult> 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();
}
39 changes: 39 additions & 0 deletions src/SynoSharp/Ssh/SynologyCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace SynoSharp.Ssh;

/// <summary>
/// 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 <c>syno*</c>
/// CLIs require root; DSM 7 has no direct root SSH, so the runner sudo's by default.
/// </summary>
public sealed record SynologyCommand
{
public required string Executable { get; init; }

public IReadOnlyList<string> Args { get; init; } = [];

/// <summary>
/// The <c>syno*</c> CLIs generally require root. DSM 7 disables direct root
/// SSH, so the runner runs this under <c>sudo</c> when true (the default).
/// </summary>
public bool RequiresRoot { get; init; } = true;

public static SynologyCommand Create(string executable, params string[] args)
=> new() { Executable = executable, Args = args };

/// <summary>Single-line, shell-quoted rendering — for dry-run display and execution.</summary>
public string Render() => string.Join(' ', new[] { Executable }.Concat(Args).Select(Quote));

/// <summary>
/// Shell-quote a token: bare if it's a safe "word" char set, else single-quoted
/// with embedded single-quotes escaped (<c>'</c> → <c>'\''</c>).
/// </summary>
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("'", "'\\''") + "'";
}
}
63 changes: 63 additions & 0 deletions src/SynoSharp/Ssh/SynologySshOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
namespace SynoSharp.Ssh;

/// <summary>
/// Connection options for the DSM SSH-runner. Distinct from <see cref="SynologyOptions"/>
/// (the HTTP Web-API read client): SSH is a separate transport/port and may use a
/// key. The login account also supplies the <c>sudo</c> password (same account),
/// since <c>syno*</c> commands need root.
/// </summary>
public sealed record SynologySshOptions
{
public required string Host { get; init; }

public int Port { get; init; } = 22;

public required string Username { get; init; }

/// <summary>Login + sudo password. Optional if <see cref="PrivateKeyPath"/> is set — but sudo still needs it.</summary>
public string? Password { get; init; }

public string? PrivateKeyPath { get; init; }

/// <summary>
/// Accept any SSH host key — the homelab pragmatic default for a self-managed
/// box (mirrors <c>VerifyTls=false</c> on the Web-API client).
/// </summary>
public bool AcceptAnyHostKey { get; init; } = true;

/// <summary>
/// Build from env: <c>SYNOLOGY_SSH_HOST</c> (falls back to the host of
/// <c>SYNOLOGY_BASE_URL</c>), <c>SYNOLOGY_SSH_PORT</c> (default 22),
/// <c>SYNOLOGY_USER</c>, <c>SYNOLOGY_PASSWORD</c>, <c>SYNOLOGY_SSH_KEY</c>.
/// Null if host or user can't be resolved.
/// </summary>
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"),
};
}
}
8 changes: 7 additions & 1 deletion src/SynoSharp/SynoSharp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
<!-- Independent SemVer. No generated .Api project — Synology has no published
schema (ADR-0002), so this is a hand-written client. -->
<VersionPrefix>0.1.0</VersionPrefix>
<Description>C# client for Synology DSM — Web-API read/discover + (later) SSH-runner for mutations. See ADR-0002.</Description>
<Description>C# client for Synology DSM — Web-API read/discover + SSH-runner for mutations. See ADR-0002.</Description>
</PropertyGroup>

<ItemGroup>
<!-- Mutation transport (ADR-0002): SSH-exec of on-box syno* / synowebapi.
Programmatic connect + sudo-via-stdin; no dependency on a system `ssh`. -->
<PackageReference Include="SSH.NET" Version="2025.1.0" />
</ItemGroup>

</Project>
Loading
Loading