From 93cee24b126c2284342e9b84211322134734bae5 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sun, 31 May 2026 20:55:27 +1200 Subject: [PATCH] =?UTF-8?q?scaffold:=20SynoSharp=20=E2=80=94=20DSM=20Web-A?= =?UTF-8?q?PI=20read/discover=20(ADR-0002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-written (no codegen — Synology has no published schema): - SynologyApiClient: SYNO.API.Auth login + entry.cgi GET ({success,data} unwrap). - SynologyDiscovery → SynologySnapshot (DSM version, shares, users); defensive SYNO.Core.* reads (degrade to empty on shape mismatch). Pinned to DSM 7.1. - synosharp CLI (discover); skippable live test (SYNOLOGY_* env). - CI/publish to GitHub Packages (chrison-dev). Builds clean; 1 unit test, live test skips. UNVERIFIED — the Virtual DSM container needs KVM/x86 (no Apple Silicon); verify on a Linux host or the live NAS read-only. SSH-runner for mutations is the next phase. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 41 ++++++++ .github/workflows/publish.yml | 37 ++++++++ .gitignore | 2 + Directory.Build.props | 19 ++++ README.md | 42 ++++++++- SynoSharp.slnx | 9 ++ global.json | 6 ++ src/SynoSharp.Cli/Program.cs | 50 ++++++++++ src/SynoSharp.Cli/SynoSharp.Cli.csproj | 20 ++++ src/SynoSharp/SynoSharp.csproj | 13 +++ src/SynoSharp/SynologyApiClient.cs | 98 ++++++++++++++++++++ src/SynoSharp/SynologyDiscovery.cs | 84 +++++++++++++++++ src/SynoSharp/SynologyOptions.cs | 37 ++++++++ src/SynoSharp/SynologySnapshot.cs | 10 ++ tests/SynoSharp.Tests/SynoSharp.Tests.csproj | 26 ++++++ tests/SynoSharp.Tests/SynologyTests.cs | 39 ++++++++ 16 files changed, 532 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 Directory.Build.props create mode 100644 SynoSharp.slnx create mode 100644 global.json create mode 100644 src/SynoSharp.Cli/Program.cs create mode 100644 src/SynoSharp.Cli/SynoSharp.Cli.csproj create mode 100644 src/SynoSharp/SynoSharp.csproj create mode 100644 src/SynoSharp/SynologyApiClient.cs create mode 100644 src/SynoSharp/SynologyDiscovery.cs create mode 100644 src/SynoSharp/SynologyOptions.cs create mode 100644 src/SynoSharp/SynologySnapshot.cs create mode 100644 tests/SynoSharp.Tests/SynoSharp.Tests.csproj create mode 100644 tests/SynoSharp.Tests/SynologyTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4385678 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + packages: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + # Hand-written client (no codegen) — just build + test. + - name: Build + run: dotnet build -c Release + + # Live DSM integration tests skip automatically without SYNOLOGY_* env. + - name: Test + run: dotnet test -c Release --no-build + + # On every push to main, publish a PRERELEASE to GitHub Packages so other + # projects can reference the latest build (e.g. 0.1.0-preview.42). Stable + # versions are published from the publish.yml workflow on a v* tag. + - name: Publish prerelease + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + dotnet pack -c Release -o artifacts --version-suffix "preview.${{ github.run_number }}" + dotnet nuget push "artifacts/*.nupkg" \ + --source "https://nuget.pkg.github.com/chrison-dev/index.json" \ + --api-key "${{ secrets.GITHUB_TOKEN }}" \ + --skip-duplicate --no-symbols diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d310111 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,37 @@ +name: publish + +# Publishes both packages to GitHub Packages: +# SynoSharp.Api (version = Synology client (no API-version tracking)) +# SynoSharp (independent SemVer, e.g. 0.1.0) +# Versions come from the csprojs; a `v*` tag (the library release) just triggers it. +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + + # Pack regenerates SynoSharp.Api from the schema, then packs the two + # packable projects (SchemaGen + Tests are IsPackable=false). + - name: Pack + run: dotnet pack -c Release -o artifacts + + - name: Push to GitHub Packages + run: > + dotnet nuget push "artifacts/*.nupkg" + --source "https://nuget.pkg.github.com/chrison-dev/index.json" + --api-key "${{ secrets.GITHUB_TOKEN }}" + --skip-duplicate --no-symbols diff --git a/.gitignore b/.gitignore index ce89292..579cba8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ *.sln.docstates *.env +src/ProxmoxSharp.Api/Generated/ + # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..7404aa2 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,19 @@ + + + + + latest + enable + enable + Chrison Simtian + Homelab + https://github.com/chrison-dev/SynoSharp + https://github.com/chrison-dev/SynoSharp + git + Unlicense + true + + embedded + + + diff --git a/README.md b/README.md index 0c24d19..35177bf 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,42 @@ # SynoSharp -C# Api Client for Synology DSM + +A C# client for **Synology DSM** IaC. Sibling to ProxmoxSharp/UnifiSharp — but +**not code-generated**: Synology publishes no settings/deploy API schema, so per +[ADR-0002](https://github.com/chrison-dev/Homelab/blob/main/docs/adr/ADR-0002-synosharp.md) +this is a hand-written **read-API client** (now) + an **SSH-runner** for mutations (later). + +## Approach + +- **Read / discover** → the DSM **Web API** (`SYNO.API.Auth` → `entry.cgi` → + `SYNO.Core.*`), returning a structured `SynologySnapshot`. *(This repo.)* +- **Mutations** (shares, NFS, users, network) → an **SSH-runner** over the on-box + `syno*` CLI + `synowebapi` (added with the write phase). The `SYNO.Core.*` + endpoints are undocumented/version-fragile — pinned to **DSM 7.1** (the DS1813+ + is EOL there). + +## Projects + +| 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. | + +## 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: DSM version, shares, users +``` + +Packages publish to GitHub Packages (chrison-dev) like the siblings: prerelease +on push to `main`, stable on `v*` tag. + +## Status + +**Read/discover scaffold — UNVERIFIED.** The Virtual DSM test container needs +KVM/x86 so it can't run on Apple Silicon; the discover path is wired but must be +verified against a DSM target (a Linux-hosted Virtual DSM, or the live NAS +read-only). The `SYNO.Core.*` reads are defensive (degrade to empty on shape +mismatch). **Next:** verify discover, then the SSH-runner for the write path. diff --git a/SynoSharp.slnx b/SynoSharp.slnx new file mode 100644 index 0000000..8dbeee3 --- /dev/null +++ b/SynoSharp.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/global.json b/global.json new file mode 100644 index 0000000..34cc7a1 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.300", + "rollForward": "latestFeature" + } +} diff --git a/src/SynoSharp.Cli/Program.cs b/src/SynoSharp.Cli/Program.cs new file mode 100644 index 0000000..471e4ef --- /dev/null +++ b/src/SynoSharp.Cli/Program.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using SynoSharp; + +// synosharp — a thin read-only CLI over the SynoSharp library. +// +// Commands: discover +// Config (env): SYNOLOGY_BASE_URL (e.g. https://nas:5001), SYNOLOGY_USER, +// SYNOLOGY_PASSWORD, SYNOLOGY_VERIFY_TLS (optional, 'false') + +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 + + Usage: synosharp + discover Dump a SynologySnapshot (DSM version, shares, users) as JSON + + Config (env): SYNOLOGY_BASE_URL (e.g. https://nas:5001), SYNOLOGY_USER, + SYNOLOGY_PASSWORD, SYNOLOGY_VERIFY_TLS (optional, 'false') + """); + return 0; +} + +var options = SynologyOptions.TryFromEnvironment(); +if (options is null) +{ + Console.Error.WriteLine("Missing config. Set SYNOLOGY_BASE_URL, SYNOLOGY_USER, SYNOLOGY_PASSWORD."); + return 2; +} + +using var client = new SynologyApiClient(options); + +switch (command) +{ + case "discover": + var snapshot = await new SynologyDiscovery(client).DiscoverAsync(); + Console.WriteLine(JsonSerializer.Serialize(snapshot, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + })); + return 0; + + default: + Console.Error.WriteLine($"Unknown command '{command}'. Try: discover"); + return 1; +} diff --git a/src/SynoSharp.Cli/SynoSharp.Cli.csproj b/src/SynoSharp.Cli/SynoSharp.Cli.csproj new file mode 100644 index 0000000..68c106c --- /dev/null +++ b/src/SynoSharp.Cli/SynoSharp.Cli.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + + true + synosharp + SynoSharp.Cli + 0.1.0 + CLI for SynoSharp — read and discover Synology DSM from the shell. + + + + + + + diff --git a/src/SynoSharp/SynoSharp.csproj b/src/SynoSharp/SynoSharp.csproj new file mode 100644 index 0000000..0f86993 --- /dev/null +++ b/src/SynoSharp/SynoSharp.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + 0.1.0 + C# client for Synology DSM — Web-API read/discover + (later) SSH-runner for mutations. See ADR-0002. + + + diff --git a/src/SynoSharp/SynologyApiClient.cs b/src/SynoSharp/SynologyApiClient.cs new file mode 100644 index 0000000..d6fbe30 --- /dev/null +++ b/src/SynoSharp/SynologyApiClient.cs @@ -0,0 +1,98 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace SynoSharp; + +/// +/// Thin Synology DSM Web API client for the read/discover path: logs in via +/// SYNO.API.Auth and issues entry.cgi calls, unwrapping the +/// { "success": …, "data": … } envelope. +/// +/// The system-admin endpoints (SYNO.Core.*) are undocumented and +/// version-fragile (ADR-0002) — this targets DSM 7.1. Mutations go through an +/// SSH-runner (separate, later); this client is read-only. +/// +/// +public sealed class SynologyApiClient : IDisposable +{ + private readonly HttpClient _http; + private readonly SynologyOptions _options; + private readonly bool _ownsHttp; + private string? _sid; + + public SynologyApiClient(SynologyOptions options, HttpClient? httpClient = null) + { + ArgumentNullException.ThrowIfNull(options); + _options = options; + + if (httpClient is null) + { + var handler = new HttpClientHandler(); + if (!options.VerifyTls) + { + handler.ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; + } + _http = new HttpClient(handler); + _ownsHttp = true; + } + else + { + _http = httpClient; + _ownsHttp = false; + } + + _http.BaseAddress = options.BaseUrl.AbsoluteUri.EndsWith('/') + ? options.BaseUrl + : new Uri(options.BaseUrl.AbsoluteUri + "/"); + } + + /// Authenticate (SYNO.API.Auth) and cache the session id. + public async Task LoginAsync(CancellationToken cancellationToken = default) + { + var query = $"webapi/auth.cgi?api=SYNO.API.Auth&version=3&method=login" + + $"&account={Uri.EscapeDataString(_options.Username)}" + + $"&passwd={Uri.EscapeDataString(_options.Password)}&session=DSM&format=sid"; + + var envelope = await _http.GetFromJsonAsync(query, cancellationToken).ConfigureAwait(false); + if (!envelope.TryGetProperty("success", out var ok) || !ok.GetBoolean()) + { + throw new InvalidOperationException("Synology login failed (SYNO.API.Auth)."); + } + _sid = envelope.GetProperty("data").GetProperty("sid").GetString(); + } + + /// + /// Call an entry.cgi API method and return its data element + /// (logs in first if needed). is appended raw. + /// + public async Task GetAsync( + string api, int version, string method, string? extraQuery = null, CancellationToken cancellationToken = default) + { + if (_sid is null) + { + await LoginAsync(cancellationToken).ConfigureAwait(false); + } + + var query = $"webapi/entry.cgi?api={api}&version={version}&method={method}&_sid={_sid}"; + if (!string.IsNullOrEmpty(extraQuery)) + { + query += "&" + extraQuery; + } + + var envelope = await _http.GetFromJsonAsync(query, cancellationToken).ConfigureAwait(false); + if (!envelope.TryGetProperty("success", out var ok) || !ok.GetBoolean()) + { + throw new InvalidOperationException($"Synology API {api}.{method} failed."); + } + return envelope.TryGetProperty("data", out var data) ? data : default; + } + + public void Dispose() + { + if (_ownsHttp) + { + _http.Dispose(); + } + } +} diff --git a/src/SynoSharp/SynologyDiscovery.cs b/src/SynoSharp/SynologyDiscovery.cs new file mode 100644 index 0000000..4862612 --- /dev/null +++ b/src/SynoSharp/SynologyDiscovery.cs @@ -0,0 +1,84 @@ +using System.Text.Json; + +namespace SynoSharp; + +/// +/// Read-only discovery over the DSM Web API → a structured . +/// +/// The SYNO.Core.* endpoints are undocumented/version-fragile (ADR-0002), +/// so each read is defensive — a differing shape on a given DSM build degrades to +/// an empty result rather than throwing. Wired against DSM 7.1 but UNVERIFIED +/// until a DSM target is available (the Virtual DSM container needs KVM/x86, so it +/// can't run on Apple Silicon — verify on a Linux host or the live NAS read-only). +/// +/// +public sealed class SynologyDiscovery +{ + private readonly SynologyApiClient _client; + + public SynologyDiscovery(SynologyApiClient client) + { + ArgumentNullException.ThrowIfNull(client); + _client = client; + } + + public async Task DiscoverAsync(CancellationToken cancellationToken = default) + { + var shares = await SafeListNamesAsync("SYNO.Core.Share", 1, "list", "shares", cancellationToken).ConfigureAwait(false); + var users = await SafeListNamesAsync("SYNO.Core.User", 1, "list", "users", cancellationToken).ConfigureAwait(false); + + string? version = null; + string? hostname = null; + try + { + var info = await _client.GetAsync("SYNO.Core.System", 1, "info", cancellationToken: cancellationToken).ConfigureAwait(false); + if (info.ValueKind == JsonValueKind.Object) + { + if (info.TryGetProperty("firmware_ver", out var fv)) + { + version = fv.GetString(); + } + if (info.TryGetProperty("hostname", out var hn)) + { + hostname = hn.GetString(); + } + } + } + catch + { + // SYNO.Core.System shape varies by DSM build; tolerate. + } + + return new SynologySnapshot + { + DsmVersion = version, + Hostname = hostname, + Shares = shares, + Users = users, + }; + } + + private async Task> SafeListNamesAsync( + string api, int version, string method, string arrayProperty, CancellationToken cancellationToken) + { + try + { + var data = await _client.GetAsync(api, version, method, cancellationToken: cancellationToken).ConfigureAwait(false); + if (data.ValueKind == JsonValueKind.Object && + data.TryGetProperty(arrayProperty, out var arr) && + arr.ValueKind == JsonValueKind.Array) + { + return arr.EnumerateArray() + .Select(e => e.TryGetProperty("name", out var n) ? n.GetString() : null) + .Where(s => !string.IsNullOrEmpty(s)) + .Select(s => s!) + .ToList(); + } + } + catch + { + // Undocumented endpoint may be absent/renamed on this DSM build; tolerate. + } + return []; + } +} diff --git a/src/SynoSharp/SynologyOptions.cs b/src/SynoSharp/SynologyOptions.cs new file mode 100644 index 0000000..7a47704 --- /dev/null +++ b/src/SynoSharp/SynologyOptions.cs @@ -0,0 +1,37 @@ +namespace SynoSharp; + +/// +/// Connection options for a Synology DSM Web API. Authenticates with a DSM +/// account (ideally a dedicated read-only one) via SYNO.API.Auth. +/// +public sealed record SynologyOptions +{ + /// Base URL of DSM, e.g. https://nas:5001 (or http://nas:5000). + public required Uri BaseUrl { get; init; } + + public required string Username { get; init; } + public required string Password { get; init; } + + /// Verify TLS. DSM commonly uses a self-signed cert — set false on the LAN. + public bool VerifyTls { get; init; } = true; + + /// + /// Build options from SYNOLOGY_BASE_URL / SYNOLOGY_USER / + /// SYNOLOGY_PASSWORD / SYNOLOGY_VERIFY_TLS; null if any required value is missing. + /// + public static SynologyOptions? TryFromEnvironment() + { + var baseUrl = Environment.GetEnvironmentVariable("SYNOLOGY_BASE_URL"); + var user = Environment.GetEnvironmentVariable("SYNOLOGY_USER"); + var pass = Environment.GetEnvironmentVariable("SYNOLOGY_PASSWORD"); + if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) + { + return null; + } + + var verifyTls = !string.Equals( + Environment.GetEnvironmentVariable("SYNOLOGY_VERIFY_TLS"), "false", StringComparison.OrdinalIgnoreCase); + + return new SynologyOptions { BaseUrl = new Uri(baseUrl), Username = user, Password = pass, VerifyTls = verifyTls }; + } +} diff --git a/src/SynoSharp/SynologySnapshot.cs b/src/SynoSharp/SynologySnapshot.cs new file mode 100644 index 0000000..41668c5 --- /dev/null +++ b/src/SynoSharp/SynologySnapshot.cs @@ -0,0 +1,10 @@ +namespace SynoSharp; + +/// A read-only snapshot of DSM state (discover output). +public sealed record SynologySnapshot +{ + public string? DsmVersion { get; init; } + public string? Hostname { get; init; } + public IReadOnlyList Shares { get; init; } = []; + public IReadOnlyList Users { get; init; } = []; +} diff --git a/tests/SynoSharp.Tests/SynoSharp.Tests.csproj b/tests/SynoSharp.Tests/SynoSharp.Tests.csproj new file mode 100644 index 0000000..453996a --- /dev/null +++ b/tests/SynoSharp.Tests/SynoSharp.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/SynoSharp.Tests/SynologyTests.cs b/tests/SynoSharp.Tests/SynologyTests.cs new file mode 100644 index 0000000..31e0a7c --- /dev/null +++ b/tests/SynoSharp.Tests/SynologyTests.cs @@ -0,0 +1,39 @@ +using Xunit; + +namespace SynoSharp.Tests; + +public class SynologyOptionsTests +{ + [Fact] + public void VerifyTls_defaults_to_true() + { + var options = new SynologyOptions + { + BaseUrl = new Uri("https://nas:5001"), + Username = "u", + Password = "p", + }; + + Assert.True(options.VerifyTls); + } +} + +/// +/// Read-only integration test against a DSM target — a Virtual DSM on a +/// KVM-capable host or the live NAS. Runs only when SYNOLOGY_* env vars are set; +/// otherwise skips. (The Virtual DSM container can't run on Apple Silicon.) +/// +public class SynologyLiveTests +{ + [SkippableFact] + public async Task Discover_returns_a_snapshot() + { + var options = SynologyOptions.TryFromEnvironment(); + Skip.If(options is null, "No SYNOLOGY_* env — skipping live DSM discovery."); + + using var client = new SynologyApiClient(options!); + var snapshot = await new SynologyDiscovery(client).DiscoverAsync(); + + Assert.NotNull(snapshot); + } +}