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);
+ }
+}