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
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
*.sln.docstates
*.env

src/ProxmoxSharp.Api/Generated/

# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs

Expand Down
19 changes: 19 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project>

<!-- Shared build settings for all SynoSharp projects. -->
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Authors>Chrison Simtian</Authors>
<Company>Homelab</Company>
<RepositoryUrl>https://github.com/chrison-dev/SynoSharp</RepositoryUrl>
<PackageProjectUrl>https://github.com/chrison-dev/SynoSharp</PackageProjectUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>Unlicense</PackageLicenseExpression>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<!-- Embed symbols in the assembly (GitHub Packages doesn't take .snupkg). -->
<DebugType>embedded</DebugType>
</PropertyGroup>

</Project>
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions SynoSharp.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/SynoSharp.Cli/SynoSharp.Cli.csproj" />
<Project Path="src/SynoSharp/SynoSharp.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SynoSharp.Tests/SynoSharp.Tests.csproj" />
</Folder>
</Solution>
6 changes: 6 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.300",
"rollForward": "latestFeature"
}
}
50 changes: 50 additions & 0 deletions src/SynoSharp.Cli/Program.cs
Original file line number Diff line number Diff line change
@@ -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 <command>
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;
}
20 changes: 20 additions & 0 deletions src/SynoSharp.Cli/SynoSharp.Cli.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<PackAsTool>true</PackAsTool>
<ToolCommandName>synosharp</ToolCommandName>
<PackageId>SynoSharp.Cli</PackageId>
<VersionPrefix>0.1.0</VersionPrefix>
<Description>CLI for SynoSharp — read and discover Synology DSM from the shell.</Description>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="../SynoSharp/SynoSharp.csproj" />
</ItemGroup>

</Project>
13 changes: 13 additions & 0 deletions src/SynoSharp/SynoSharp.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- 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>
</PropertyGroup>

</Project>
98 changes: 98 additions & 0 deletions src/SynoSharp/SynologyApiClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using System.Net.Http.Json;
using System.Text.Json;

namespace SynoSharp;

/// <summary>
/// Thin Synology DSM Web API client for the read/discover path: logs in via
/// <c>SYNO.API.Auth</c> and issues <c>entry.cgi</c> calls, unwrapping the
/// <c>{ "success": …, "data": … }</c> envelope.
/// <para>
/// The system-admin endpoints (<c>SYNO.Core.*</c>) 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.
/// </para>
/// </summary>
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 + "/");
}

/// <summary>Authenticate (<c>SYNO.API.Auth</c>) and cache the session id.</summary>
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<JsonElement>(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();
}

/// <summary>
/// Call an <c>entry.cgi</c> API method and return its <c>data</c> element
/// (logs in first if needed). <paramref name="extraQuery"/> is appended raw.
/// </summary>
public async Task<JsonElement> 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<JsonElement>(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();
}
}
}
Loading
Loading