diff --git a/.claude/skills/release-notes/SKILL.md b/.claude/skills/release-notes/SKILL.md index eb4be83..b54e69f 100644 --- a/.claude/skills/release-notes/SKILL.md +++ b/.claude/skills/release-notes/SKILL.md @@ -14,9 +14,10 @@ description: >- # Release notes The notes for a release are **`.docs/release-notes/vX.Y.Z.md`**. The VS Code task **release (GitHub)** -(`.github/scripts/publish-release.ps1`) publishes that file unchanged as the GitHub release's notes -(`.github/scripts/release-notes.ps1`), and builds it into the exe - so it is written and committed before the release. -The workflow on GitHub (`.github/workflows/ci.yml`) only builds and tests the pushed tag. [`.docs/release-notes/v1.6.0.md`](../../../.docs/release-notes/v1.6.0.md) is the model: match +(`.github/scripts/publish-release.ps1`) pushes the version tag, and the release workflow on GitHub +(`.github/workflows/release.yml`) builds the exes from that tag - this file built into them - and publishes it +unchanged as the GitHub release's notes (`.github/scripts/release-notes.ps1`). So it is written and committed before +the release: a commit without it isn't released. [`.docs/release-notes/v1.6.0.md`](../../../.docs/release-notes/v1.6.0.md) is the model: match its structure, length and tone. Readers are **people who use DNN Manager**, deciding whether to update and what changes for them - not developers. @@ -49,22 +50,22 @@ Readers are **people who use DNN Manager**, deciding whether to update and what report, ready to paste. 8. Report what the notes say, the commit message, and anything left unverified. Don't commit, tag or publish - the owner does, with the VS Code task **release (GitHub)** (`.github/scripts/publish-release.ps1`, see - `.docs/releasing.md`), which picks this file and a commit, builds both exes and publishes the release - or, for a - redo, **release: redo (GitHub)** (below). + `.docs/releasing.md`), which picks this file and a commit and pushes the tag; GitHub's release workflow builds + both exes and publishes the release - or, for a redo, **release: redo (GitHub)** (below). ## Redoing a release The version's tag is made already - its release workflow failed, or one more change is wanted - and the owner wants the same version again, not the next one. `.github/scripts/redo-release.ps1` (the VS Code task **release: redo (GitHub)**, see `.docs/releasing.md#redo-a-release`) amends the release commit with the working copy, -pushes the branch, deletes the tag and its GitHub release, and releases the version again. +pushes the branch, deletes the tag and releases the version again (a draft the failed run left is reused). It refuses +a release that is published. -1. **Is a redo still right?** Look the release up: +1. **Is a redo still possible?** Look the release up: `curl -s https://api.github.com/repos///releases/tags/vX.Y.Z` (the repository is `origin`'s) - a - 404 means none is published (a draft isn't listed there). When it is published, give its date and the files' - `download_count`s: DNN Manager updates only to a *newer* version, so whoever installed the first build is never - offered the redone one. If anyone but the owner may have it, recommend the next version instead and let the owner - decide. + 404 means none is published (a draft isn't listed there). When it is published, a redo is refused: DNN Manager + updates only to a *newer* version, so whoever installed it would never be offered the redone one. Recommend the + next version instead. 2. **The range** is the tag before it to the working copy: the previous release is `git describe --tags --abbrev=0 vX.Y.Z^`, and the changes are its commits plus what isn't committed yet (`git status`, `git diff HEAD`) - the redo folds them into the release commit. @@ -75,7 +76,7 @@ pushes the branch, deletes the tag and its GitHub release, and releases the vers (Enter keeps the old one). 5. **Show the plan, don't run it**: `.github\scripts\redo-release.ps1 -Version X.Y.Z -DryRun` changes nothing and prints what the redo would amend, push and delete - fine to run and report. The redo itself force-pushes the - branch and deletes a release: only the owner runs it. + branch and deletes the tag: only the owner runs it. ## Structure diff --git a/.docs/adr/0001-undo-on-failure.md b/.docs/adr/0001-undo-on-failure.md index f7ae57e..20e828d 100644 --- a/.docs/adr/0001-undo-on-failure.md +++ b/.docs/adr/0001-undo-on-failure.md @@ -1,6 +1,7 @@ # ADR 0001 - A failed operation is undone, like a cancelled one -**Status:** accepted (2026-10) +**Status:** accepted (2026-10); amended (2026-10) - undo steps have time limits, +an undo can be stopped, an unfinished operation is recorded ## Context @@ -29,7 +30,21 @@ journal at all. undo) doesn't add to the journal while it runs. - Quitting while an operation runs cancels it and closes the window only once it has stopped and undone what it did (`OperationRunner.WhenIdleAsync`). Setup, which - is waiting to replace the files, gives it 20 seconds. + is waiting to replace the files, gives it 20 seconds; Windows signing out or + shutting down, 60 (held up once, with a reason). +- **The undo itself is bounded** (amended): an undo that waits on a SQL Server or + IIS that doesn't answer would otherwise keep the operation - and quitting - from + ever ending. Each step gets a token cancelled after its time + (`OperationUndo.StepLimit` 60 s, `FileStepLimit` 30 s, or a step's own - 20 min + for putting an upgraded site back), the whole undo about 5 minutes + (`TotalLimit`); a step whose time is up is named as not undone and the next one + runs. **Cancel pressed again** while undoing - *Stop undoing* - and **Quit now** + (offered after 20 seconds of quitting) stop the undo the same way. +- **An unfinished operation is recorded** (amended): while an operation runs, its + title and what it would undo now (`OperationUndo.Pending`) are kept in the + `state` table's `operation` area, deleted when it ends. Still there at a start, + DNN Manager ended first (a crash, a shutdown, *Quit now*), and says what may be + left half done. ## Consequences @@ -39,7 +54,10 @@ journal at all. folder that was there) is said in the Output tab, after a failure as after a cancel. - Quitting can take a while: an operation that ignores its cancel (Restore, once it - has started changing the site) runs to its end first. + has started changing the site) runs to its end first - *Quit now* ends the wait, + leaving it as it is. +- A stopped or timed-out undo can leave something half made; it is named in the + Output tab, or at the next start - never left unsaid. - Options considered: *keep everything for inspection* (the old behaviour - it blocked retrying and left orphans nobody cleaned up) and *undo only before the install step* (two rules to explain instead of one, with `Keep()` covering the diff --git a/.docs/adr/0003-update-trust.md b/.docs/adr/0003-update-trust.md index 1858f2f..4298f75 100644 --- a/.docs/adr/0003-update-trust.md +++ b/.docs/adr/0003-update-trust.md @@ -1,6 +1,8 @@ # ADR 0003 - What an update is trusted on -**Status:** accepted (2026-10) +**Status:** accepted (2026-10); amended (2026-10) - releases built in CI with +build provenance; amended again (2026-10) - publishing approved by a reviewer, +the tag checked again, signing in a job of its own, a smoke test, the launcher ## Context @@ -16,6 +18,12 @@ Administrator rights. Until 1.8.0: started testing its tag; - the exes aren't signed. +Up to 1.8.1, the published files were also built on the publisher's PC. CI rebuilt +and tested the tag, but it was the PC's build that was uploaded, with a broad +GitHub credential that the release script read from Git. Anyone who controlled +that PC, or the tools it cached (the extracted Inno Setup compiler), controlled +what every DNN Manager installed - and nothing showed where a file had come from. + ## Decision - **The file of the release, or nothing:** a release that lists no SHA-256 isn't @@ -24,21 +32,67 @@ Administrator rights. Until 1.8.0: `%ProgramData%\DnnManager\temp\update` (only administrators can change it), and the helper checks the file's size and SHA-256 again right before it copies or runs it, holding it open meanwhile. -- **Tested before it is offered:** [`publish-release.ps1`](../../.github/scripts/publish-release.ps1) - keeps the release a draft until CI has passed on its tag, and checks GitHub's - digest of every file it uploads. `-SkipTests` and `-SkipCi` are for pre-releases, - which nobody is offered. +- **Built by GitHub, from the tag:** the + [release workflow](../releasing.md#the-release-workflow) + ([`release.yml`](../../.github/workflows/release.yml)) builds both exes from the + pushed tag on a pinned runner image. It uses the SDK `global.json` names, the + packages `packages.lock.json` names (from nuget.org only, through `nuget.config`) + and the pinned Inno Setup, which is extracted again from its hash-checked package + on every build. Its actions are pinned to commits. The publisher's PC only runs + the fast tests and pushes the tag; nothing it builds is uploaded. +- **Provenance:** the workflow attests both exes + (`actions/attest-build-provenance`, signed by GitHub through Sigstore), so + `gh attestation verify --repo Albadit/DnnManager.NET` shows which + workflow, tag and commit produced a file. +- **Tested and tried before it is offered:** the release stays a draft until the + whole test suite (CI, called on the tag), the build, the signing and a smoke test + have passed - Setup installed on a fresh runner, DNN Manager started through its + launcher, uninstalled, and the portable exe started. Only then are the files + uploaded to a draft for the commit that was built, and GitHub's digest of each + checked. +- **A person approves the publish:** the publish job runs in the GitHub + environment `publish`, which needs a required reviewer's approval. Right before + publishing it checks again that the tag still points at the commit that was + built and that the draft holds exactly the files in `SHA256SUMS.txt`. A release + becomes the latest only when its version is newer than the latest one. +- **Least privilege:** each job gets only the permissions it needs. The build job, + which runs the packages' and the tests' code, gets no OpenID Connect token; the + sign job, which restores no package and runs no test, is the only one that can + sign in to Azure (just before the first file is signed) and attest. Only the + draft and publish jobs may write to the repository. The workflow's own + `GITHUB_TOKEN` is its only GitHub credential. The release scripts read no token: + Git's credential pushes the tag, and the GitHub API is asked without signing in. +- **A published version never changes:** the release workflow and + `redo-release.ps1` refuse to touch a published release. A change to one gets a + new version number. With GitHub's immutable releases turned on, neither its files + nor its tag can change any more. +- **Signing, ready to switch on:** with Azure Artifact Signing's repository + variables set, the workflow signs `DnnManager.exe`, the launcher + (`DnnManager-launcher.exe`), the portable exe, Setup and its uninstaller + (Authenticode, timestamped), and fails rather than publish an unsigned file - + and with `REQUIRE_SIGNING` set, a run without the variables fails too. It signs + in to Azure with OpenID Connect, and there is no signing secret in the + repository ([Code signing](../releasing.md#code-signing)). ## Consequences - An update can't be swapped on this PC by a program without administrator rights, and a release nobody tested isn't offered. -- What is trusted is still the GitHub release: whoever can publish one (the - publisher's GitHub credential) can publish an update every DNN Manager installs. - **Code signing** (Authenticode, with the helper checking the signer) is the next - step - it needs a certificate or a signing service, and goes into `build.ps1` - and `DnnManager.iss` (`SignTool`). Until then it is listed under - [Known open risks](../security.md#known-open-risks). -- Building releases in CI (with build provenance) instead of on the publisher's - PC remains open: today CI rebuilds and tests the tag, and the PC's build is - what is published. +- A compromised publisher PC can no longer slip its own build into a release: + what is published is what the workflow built from the tag, and the attestation + shows it. Releases up to 1.8.1 have no attestation. +- **What remains trusted is whoever can push a version tag and approve the + publish job**, or change the workflow and then tag. A tag alone builds a draft; + publishing waits for the `publish` environment's reviewer - once the owner has + added one on GitHub (without it the job runs at once). DNN Manager installs any + release whose file matches GitHub's SHA-256. The tag and branch rulesets and the + environments ([Set up GitHub](../releasing.md#set-up-github)) limit who that is. + The attestation shows where a file came from, but DNN Manager doesn't check it. +- **The sign job still compiles Setup**: Inno Setup (the pinned, hash-checked + copy) runs in the job that can sign in to Azure - an accepted residual risk. +- **Until signing is set up, the gap stays:** the exes are unsigned, Windows + SmartScreen warns about them, and the helper can't check a signer before it runs + an update. The next steps are to set up Artifact Signing (the workflow is ready + for it), then have the update helper and Setup check that the downloaded file is + signed by the publisher's certificate. Until then the exes' missing signature + stays under [Known open risks](../security.md#known-open-risks). diff --git a/.docs/adr/0004-elevation-boundary.md b/.docs/adr/0004-elevation-boundary.md new file mode 100644 index 0000000..e68161a --- /dev/null +++ b/.docs/adr/0004-elevation-boundary.md @@ -0,0 +1,96 @@ +# ADR 0004 - What the user's account can change goes through a few checked choke points + +**Status:** accepted (2026-10) + +## Context + +DNN Manager is a desktop app that runs elevated, as the same user who signs in. +That user's everyday programs run without administrator rights - and they can +change a lot of what DNN Manager reads and acts on: + +- its settings (`Documents\DnnManager\dnnmanager.db`) and the user's environment + variables (`DNNMANAGER_*`, but also .NET's own: a profiler, a startup hook, a + diagnostic port); +- the folders it writes, unpacks and deletes in: `%TEMP%`, Documents, the + projects folder - and a site's folder, which its app pool can write too; +- the programs it starts: anything on the user's PATH or in a per-user install; +- a site's `web.config`, which names the database DNN Manager drops or signs in + to, and is XML the site can change. + +Each of these is a way for a program without administrator rights to get them: +a junction under a folder DNN Manager deletes, a `docker.exe` earlier on the +PATH, a `CORECLR_PROFILER` that loads a DLL into the elevated process, a +projects folder set to `C:\Windows`. Up to 1.8.1 the checks were made at call +sites, one by one, and each new feature had to remember them. + +## Options + +1. **A check at each call site** - as before. Cheap to start, but nothing tells a + new call site it needs one, and the checks drift apart. +2. **A few choke points, each enforced by tests** - one class per kind of input + that every use goes through, and tests that fail when code goes around them. +3. **A broker split**: a small elevated service that does only the + administrator work, with a UI that runs as the user. The cleanest boundary, + but a second process and a protocol between them, an installer service, and + most of the app rewritten. + +## Decision + +Option 2. The choke points: + +- **Settings and environment**: + [`SettingRules`](../../src/DnnManager.Application/Configuration/SettingRules.cs) holds + every value that feeds an administrator action (the projects folder, release + sources, the hostname suffix, the collation, Docker names, IIS feature names), + for the saved settings (`UserSettings.Validate`) and the `DNNMANAGER_*` + overrides (`AppOptions.Problems`) alike. A saved value that breaks a rule goes + back to its default; overrides that break one are all ignored. +- **Folders others can write**: + [`SafePath`](../../src/DnnManager.Application/SafePath.cs) (is a path inside a + folder, links on the way, deleting a tree) and + [`SafeZip`](../../src/DnnManager.Infrastructure/Files/SafeZip.cs) (every zip + entry unpacked) are the only ways to write, extract or delete there, on top of + Windows' redirection trust for the process + ([`RedirectionTrust`](../../src/DnnManager.Infrastructure/Files/RedirectionTrust.cs)). + A site's XML is read through + [`SiteXml`](../../src/DnnManager.Infrastructure/WebConfigs/SiteXml.cs) (no DTD). +- **Starting a program elevated**: only + [`ProcessRunner`](../../src/DnnManager.Infrastructure/Processes/ProcessRunner.cs) + and [`ElevatedStart`](../../src/DnnManager.Infrastructure/Processes/ElevatedStart.cs), + which take only a copy administrators alone can change + (`TrustedPrograms.Resolve`) and give it + [`ChildEnvironment`](../../src/DnnManager.Infrastructure/Processes/ChildEnvironment.cs)'s + environment. A program for the user starts as the user. +- **DNN Manager's own start**: the launcher + ([`src/DnnManager.Launcher`](../../src/DnnManager.Launcher/DnnManager.Launcher.csproj)), + a Native AOT exe that drops .NET's variables before it starts `DnnManager.exe` - + what the sign-in task and the UAC relaunch start. +- **Terminals**: without administrator rights by default (a restricted copy of + DNN Manager's token); an Administrator terminal only when asked for, with + shells installed for all users and no profile scripts. +- **The tests**: `LayeringTests` scans the source and fails on a `Process.Start` + or `new ProcessStartInfo` outside the allowed files, and on a Presentation use + of an Infrastructure namespace not on its list; `ElevationBoundaryTests`, + `SecurityHardeningTests`, `SettingsResilienceTests` and + `LauncherEnvironmentTests` hold the rules themselves ([testing.md](../testing.md)). + +## Consequences + +- A new feature that deletes, unpacks, starts a program or reads a setting gets + the checks by using the one class there is for it; one that goes around + `ProcessRunner` / `ElevatedStart` fails the fast tests. +- The source scan is textual, like the layering test: it doesn't see a process + started through reflection or a library. Code review still has to look. +- Residual risks are accepted and listed under + [Known open risks](../security.md#known-open-risks): the portable exe has no + launcher and extracts its native DLLs into `%TEMP%\.net`; the SQL container's + `sa` password is in its environment and SqlPackage's passwords on its command + line; an Administrator terminal runs what is typed with those rights; an + unelevated terminal runs in the elevated sign-in session. +- **The installer's default follows from it**: Setup installs for all users, into + Program Files, where only administrators can change the exe that runs elevated - + and the sign-in task is made only for such an installation. A per-user + installation (`/CURRENTUSER`, or one made by 1.8.1 and older) keeps working, + with a warning, and is offered the move. +- Option 3 (a broker) stays open as the stronger boundary. The choke points are + where its interface would be cut. diff --git a/.docs/architecture.md b/.docs/architecture.md index 91638e6..398e48d 100644 --- a/.docs/architecture.md +++ b/.docs/architecture.md @@ -52,7 +52,17 @@ flowchart TD - The rule is checked by a test: [`LayeringTests`](../tests/DnnManager.IntegrationTests/LayeringTests.cs) fails when Domain uses anything of DnnManager's, Application uses Infrastructure or Presentation, or Infrastructure uses Presentation - ([ADR 0002](adr/0002-layering-rule.md)). + ([ADR 0002](adr/0002-layering-rule.md)). It also holds Presentation to the + Infrastructure namespaces it uses today (an allowlist - a new one is added on + purpose, or goes through an Application interface), and fails on a + `Process.Start` or `new ProcessStartInfo` outside the few files allowed to + start a process ([ADR 0004](adr/0004-elevation-boundary.md)). +- **One more project**, [`src/DnnManager.Launcher`](../src/DnnManager.Launcher/DnnManager.Launcher.csproj): + `DnnManager-launcher.exe`, compiled with Native AOT. It has nothing of the app + but [`Startup/LaunchEnvironment.cs`](../src/DnnManager.Infrastructure/Startup/LaunchEnvironment.cs), + linked in. The sign-in task and the UAC relaunch start it when it is beside + `DnnManager.exe` (an installed DNN Manager); it drops .NET's variables from the + environment and starts `DnnManager.exe` ([security.md](security.md#administrator-rights)). ## How an operation runs @@ -93,10 +103,30 @@ case notes how to take back what it is about to make in the scope's what can't be taken back (a dropped database) is noted as such. Nothing half made is left behind to get in the way of trying again - unless the use case keeps it on purpose (`OperationUndo.Keep`), as New project does with a DNN installation that -failed, to look into ([ADR 0001](adr/0001-undo-on-failure.md)). Quitting while an -operation runs cancels it and closes the window once it has stopped and undone what -it did (`OperationRunner.WhenIdleAsync`); Setup, which is waiting to replace the -files, gives it 20 seconds. +failed, to look into ([ADR 0001](adr/0001-undo-on-failure.md)). + +- **Each undo step has a time limit** and gets a token cancelled when it is up + (`OperationUndo.StepLimit` 60 s for SQL and IIS, `FileStepLimit` 30 s for + files, longer where a step says so - 20 min for putting an upgraded site back), + and the whole undo `TotalLimit` (5 min): a SQL Server or IIS that doesn't + answer can't keep it, or quitting, from ever ending. What isn't done in time is + named as not undone. **Cancel pressed again** while undoing (*Stop undoing*) + stops the undo the same way (`OperationRunner.SkipUndo`). +- **Quitting** cancels the operation and closes the window once it has stopped and + undone what it did (`OperationRunner.WhenIdleAsync`); after 20 s a toast offers + **Quit now**. Setup, which is waiting to replace the files, gives it 20 s and + Windows signing out or shutting down 60 s (the shutdown is blocked once, with a + reason) - then the undo is stopped and DNN Manager quits. +- **An operation that doesn't end** is recorded while it runs: its title, start + and `OperationUndo.Pending` (what it would undo now) in the `state` area + `operation` (`UnfinishedOperation`), written as it changes and deleted when it + ends. Still there at a start, DNN Manager ended first, and `MainWindow` says + what may be left half done. +- **A cancel reaches the SQL Server**: a `BACKUP` or `RESTORE` in the container + runs under a session tag (`sqlcmd -H`), and a cancel ends it there (`pkill` in + the container, then `KILL` of the tagged session) before the undo drops what it + was making. Every program `ProcessRunner` starts is in a Windows job object that + ends with DNN Manager. ```mermaid stateDiagram-v2 @@ -104,6 +134,7 @@ stateDiagram-v2 Running --> Finished : Result.Ok Running --> Undoing : Cancel (status bar, or quitting) Running --> Undoing : Result.Fail or an exception + Undoing --> Undoing : a step's time is up - named, the next one runs Undoing --> Cancelled : cancelled - OperationUndo has run, last step first Undoing --> Failed : failed - OperationUndo has run, last step first Running --> Failed : failed, with nothing to undo (or kept to look into) @@ -219,6 +250,7 @@ rest is state, kept apart in the `state` table, one row per value by area: | `update` | The update under way (from, to, the helper's result file) | `AppUpdater` writes it; `MainWindow` reads and deletes it | | `palette` | The commands last run from the command palette (the newest 8), listed first as *recently used* | `MainWindow.CommandItems` / `Remember` | | `version` | The version that ran last - a newer one shows What's new (the release notes since) at its first start | `MainWindow.RestoreWorkspace`, [`ReleaseNotes`](../src/DnnManager.Presentation/Services/ReleaseNotes.cs) | +| `operation` | Only while an operation runs: its title, when it started and what it would undo now - not part of the workspace; deleted when it ends | `OperationRunner` writes it ([`UnfinishedOperation`](../src/DnnManager.Presentation/Services/OperationRunner.cs)); `MainWindow.ReportUnfinished` reads it at the next start | | `onboarding` | The version of the getting started guide finished or skipped (`Onboarding.Version`) - the very first start shows the guide, a newer guide only its new pages; the version of the first start - still on it, What's new isn't offered (`MainWindow.IsNewUser`) | `MainWindow.ShowGuideAtStart`, [`Onboarding.AtStart`](../src/DnnManager.Presentation/Services/Onboarding.cs) | - **One place reads and writes them**: @@ -266,7 +298,7 @@ DNN Manager's own data is one SQLite file, `Documents\DnnManager\dnnmanager.db` | `state` | The workspace, one row per value by area (`area`, `key`, `value`) | [`StateStore`](../src/DnnManager.Infrastructure/State/StateStore.cs) | | `projects` | How DNN Manager installed each project it set up | [`ProjectRecords`](../src/DnnManager.Infrastructure/Projects/ProjectRecords.cs) | | `keep_warm` | The sites kept warm, a row each - how they are kept warm is the settings' | [`KeepWarmRecords`](../src/DnnManager.Infrastructure/Projects/KeepWarmRecords.cs) | -| `dnn_releases` | Each repository's DNN versions as GitHub last listed them, for offline use | [`GitHubDnnReleaseService`](../src/DnnManager.Infrastructure/Github/GitHubDnnReleaseService.cs) | +| `dnn_releases` | Each repository's DNN versions as GitHub last listed them, for offline use - with GitHub's SHA-256 of the install and upgrade packages (`sha256`, `upgrade_sha256`), so a kept package is checked offline too | [`GitHubDnnReleaseService`](../src/DnnManager.Infrastructure/Github/GitHubDnnReleaseService.cs) | - **One file, opened for each read or write** and closed after it (no connection pool), so nothing holds it open in between; a write waits up to @@ -274,7 +306,8 @@ DNN Manager's own data is one SQLite file, `Documents\DnnManager\dnnmanager.db` `AppDatabase.Open` brings the tables up to date the first time - and again for a file deleted meanwhile: `AppDatabase.Steps` are the numbered steps that make each version of the tables from the one before, and `PRAGMA - user_version` holds how many have run. A change of the tables is a new step + user_version` holds how many have run (5 in this version - the fifth added + the SHA-256 columns of `dnn_releases`). A change of the tables is a new step at the end; the steps before never change. The steps that haven't run, and the count, run in **one** `BEGIN IMMEDIATE` transaction, counted again under that lock (threads opening a new file at once each saw 0 - running a step @@ -319,11 +352,14 @@ DNN Manager's own data is one SQLite file, `Documents\DnnManager\dnnmanager.db` ## Project layout One `.csproj` at the root; the source is organised by layer under `src/` and -compiled into a single assembly (`DnnManager.exe`). +compiled into a single assembly (`DnnManager.exe`). The launcher +(`src/DnnManager.Launcher`) and the tests are projects of their own. ``` DnnManager.NET/ -├── DnnManager.csproj ← single project (net10.0-windows, WPF WinExe) +├── DnnManager.csproj ← the app (net10.0-windows, WPF WinExe) +├── global.json ← the exact .NET SDK (rollForward: disable) +├── nuget.config ← packages from nuget.org only (source mapping) ├── app.manifest ← asInvoker; AdminElevation relaunches elevated ├── tests/ │ └── DnnManager.IntegrationTests/ ← MSTest: fast unit tests, and the automatic DNN setup end to end (IIS Express, LocalDB, Docker) - see testing.md @@ -333,26 +369,27 @@ DnnManager.NET/ │ ├── ProjectName.cs ← project name validation │ └── Result.cs ← Result / Result (no exceptions across layers) ├── DnnManager.Application/ + │ ├── SafePath.cs ← paths in folders others can write: inside a folder, links on the way, deleting a tree │ ├── Abstractions/ ← the interfaces use cases consume (one file each), SqlServerAddress, the records they exchange - │ ├── Configuration/ ← UserSettings (the settings' layout, defaults, validation), AppOptions + │ ├── Configuration/ ← UserSettings (the settings' layout, defaults, validation), AppOptions, SettingRules (the rules for what feeds admin actions) │ ├── UseCases/ ← one class per top-level action; OperationUndo, Provisioning (IIS site, local container, site logins), SiteDatabases │ └── DependencyInjection.cs ├── DnnManager.Infrastructure/ │ ├── Iis/ ← IIS via Microsoft.Web.Administration - │ ├── Docker/ ← docker-compose.yml for the shared SQL container, from the settings, and running it - │ ├── Sql/ ← sqlcmd in the container, remote backup, SqlPackage, connection test, SiteDatabaseChecks (is a site's database live - the Projects table and Host project); DatabaseProvisioner (Test connection, create, the site's login), LocalDB files, connection strings + │ ├── Docker/ ← docker-compose.yml for the shared SQL container (pinned image), from the settings, and running it; DockerNames + │ ├── Sql/ ← sqlcmd in the container, remote backup, SqlPackage, connection test, SiteDatabaseChecks (is a site's database live - the Projects table and Host project); DatabaseProvisioner (Test connection, create, the site's login), LocalDB files, connection strings (ConnectionStrings.For - the one connection policy), SqlText (quoting names and values for SQL) │ ├── Dnn/ ← DNN's unattended install (Install.aspx), its template and output, the host password's hash; DnnLogFiles (reading DNN's logs) - │ ├── Github/ ← GitHub API + DNN package downloader + │ ├── Github/ ← GitHubDnnReleaseService (the release list), DnnPackageInstaller (download, check, extract), HttpConnectivityChecker │ ├── Data/ ← AppDatabase (dnnmanager.db, its tables and their steps), ValueRows (an object as key / value rows) │ ├── Settings/ ← AppDataPaths (Documents\DnnManager), SettingsStore, AppDataCleaner, WindowsCredentialStore │ ├── State/ ← StateStore (the workspace, by area) - │ ├── Files/ ← file copy, site .zip import / export, daily log file; PrivateTemp (the admin-only temporary and tools folders), ProjectsFolderGuard, StalledRead + │ ├── Files/ ← file copy, site .zip import / export, daily log file; PrivateTemp (the admin-only temporary and tools folders), ProjectsFolderGuard, StalledRead, SafeZip (every zip unpacked), RedirectionTrust │ ├── Projects/ ← file-system project repository; ProjectRecords (how each project was installed), KeepWarmRecords │ ├── Prereq/ ← IIS feature checks - │ ├── WebConfigs/ ← web.config SiteSqlServer read / write - │ ├── Processes/ ← shared ProcessRunner (with TrustedPrograms: only admin-only programs run elevated); the app's own power throttling (EcoQoS) - │ ├── Terminal/ ← a shell in a Windows pseudo console (ConPTY) - │ ├── Startup/ ← the "start at sign-in" scheduled task + │ ├── WebConfigs/ ← web.config SiteSqlServer read / write; SiteXml (a site's XML, read without DTDs) + │ ├── Processes/ ← ProcessRunner and ElevatedStart - the only ways to start a program elevated - with TrustedPrograms (only admin-only programs) and ChildEnvironment (what they inherit); the app's own power throttling (EcoQoS) + │ ├── Terminal/ ← a shell in a Windows pseudo console (ConPTY), without administrator rights (UnelevatedToken) unless asked + │ ├── Startup/ ← the "start at sign-in" scheduled task; LaunchEnvironment (what the launcher starts DNN Manager with) │ ├── Monitoring/ ← ServerStateMonitor: the live state of the projects, IIS and this PC - what tells it to look (ChangeSources) and what it measures with │ ├── KeepWarm/ ← KeepWarmService (one loop, a channel of messages) with its rules, plan and requester │ ├── Hosts/ ← HostsFileService: the sites' host names in the hosts file, following the monitor @@ -360,12 +397,13 @@ DnnManager.NET/ │ ├── SiteLogs/ ← a site's logs (DNN, IIS, event logs) and following one as it is written │ └── DependencyInjection.cs ├── DnnManager.Installer/ ← not compiled into the app - │ ├── DnnManager.iss ← Inno Setup script (per-user install, shortcuts, uninstall) - │ ├── build.ps1 ← publish + compile the installer + │ ├── DnnManager.iss ← Inno Setup script (install for all users - /CURRENTUSER for one -, shortcuts, uninstall) + │ ├── build.ps1 ← publish the app and the launcher + compile the installer │ └── bin/ ← build files (published app, wizard images, Inno Setup) - not in git + ├── DnnManager.Launcher/ ← DnnManager-launcher.exe (Native AOT): starts DnnManager.exe without .NET's variables └── DnnManager.Presentation/ ├── Program.cs ← composition root (settings + Host + DI, logging to the daily log file), starts WPF - ├── AdminElevation.cs ← relaunches elevated when needed + ├── AdminElevation.cs ← relaunches elevated when needed - through the launcher when it is beside the exe ├── AppRestart.cs ← Troubleshoot → Restart, and restarting after a reset ├── Shell.cs ← opens an address, folder or file through Explorer - as the user, not as Administrator ├── Unelevated.cs ← starts a program (an IDE) with arguments as the user, through the desktop's shell @@ -376,11 +414,11 @@ DnnManager.NET/ ├── Pages/ ← one page per sidebar item, plus Settings and Troubleshoot (opened over the page); AboutInfo (Settings → About) │ └── Projects/ ← the Projects table's row, columns and right-click menu; a site's Details (ProjectView, ProjectDiagnostics, Inspector) ├── Assets/ ← dnn.ico - the exe and window icon (DNN logo mark) - ├── Controls/ ← StatusBar, IisStatus, TerminalPanel (Output / Logs / Terminal), PipelineView + PipelineLog (the Output tab), LogView, LogsView, PanelSearch, ToastView, InputDialog, MessageDialog, ExistingFolderOptions, PasswordInput, DatabaseCheckList, HostPasswordDialog, the edit dialogs (RenameProjectDialog, BindingsDialog, AppPoolDialog, DatabaseConnectionDialog, DeploymentExportDialog, IisFeaturesDialog), WhatsNewDialog + MarkdownDocument (the release notes); DockerCard, DatabaseServerCard, IisCard (Settings' Test and set up cards) + ├── Controls/ ← StatusBar, IisStatus, TerminalPanel (Output / Logs / Terminal), PipelineView + PipelineLog (the Output tab), LogView, LogsView, PanelSearch, ToastView, InputDialog, MessageDialog, ExistingFolderOptions, PasswordInput, FieldError (a form's error as its field's help text, announced), DatabaseCheckList, HostPasswordDialog, the edit dialogs (RenameProjectDialog, BindingsDialog, AppPoolDialog, DatabaseConnectionDialog, DeploymentExportDialog, IisFeaturesDialog), WhatsNewDialog + MarkdownDocument (the release notes); DockerCard, DatabaseServerCard, IisCard (Settings' Test and set up cards) ├── Terminal/ ← the terminal itself: screen buffer + VT parser, the view that draws it, the shell session ├── Themes/ ← LightTheme / DarkTheme colour palettes, Tokens (radii, heights, padding), Icons (every icon) │ └── Controls/ ← the reusable control styles, one dictionary per kind (see Control styles) - └── Services/ ← ActivityLog + OutputModel (the Output tab's runs, stages and lines), OperationRunner, ServerStore, EfficiencyMode + WindowOcclusion, TrayIcon (the notification area's icon), LiveSettings, DnnReleaseCatalog, ReleaseNotes (the notes built into the exe), TerminalService, ThemeManager (with Windows' Contrast themes), Toast (and screen-reader announcements), AccessibleName, SecretClipboard, IdeLocator, SsmsConnectDialog, SettingsStartup, ByteSize, GUI adapters + └── Services/ ← ActivityLog + OutputModel (the Output tab's runs, stages and lines), OperationRunner, ServerStore, EfficiencyMode + WindowOcclusion, TrayIcon (the notification area's icon), LiveSettings, DnnReleaseCatalog, ReleaseNotes (the notes built into the exe), TerminalService, ThemeManager (with Windows' Contrast themes), Toast (ToastQueue: toasts that stay wait their turn; screen-reader announcements), AccessibleName, SecretClipboard, IdeLocator, SsmsConnectDialog, SettingsStartup, ByteSize, GUI adapters ``` ## Key design decisions @@ -396,15 +434,15 @@ DnnManager.NET/ | **WPF, code-behind pages** | One `UserControl` per sidebar item, made on its first visit and kept, so its lists load once (Settings is made anew each time). | | **Live state instead of Refresh** | One monitor reads the system and one store holds what the window shows. Windows' own notifications say when to look; timers cover what has none. See [Live updates](#live-updates). | | **Use cases off the UI thread** | `OperationRunner` runs one use case at a time on the thread pool in its own DI scope, refuses a second one while it runs, and backs the status bar's **Cancel** button. A cancelled or failed operation is undone through the scope's [`OperationUndo`](../src/DnnManager.Application/UseCases/OperationUndo.cs): each step notes how to take back what it is about to make, before it starts ([ADR 0001](adr/0001-undo-on-failure.md)). | -| **Nothing elevated from where the user can write** | DNN Manager runs as Administrator: a program it starts, or a file it writes and reads back, in a folder any program of the user's could change would hand those programs Administrator rights. Programs are looked up through `TrustedPrograms`, temporary files go to `PrivateTemp` (`%ProgramData%\DnnManager\temp`), the projects folder is kept to administrators and you (`ProjectsFolderGuard`), and editors start as the user (`Unelevated`). See [security.md](security.md#administrator-rights). | -| **Updates checked twice** | An update is checked against GitHub's SHA-256 when it is downloaded and again by the elevated helper right before it runs it, from a folder only administrators can change; releases are published only once CI passed on their tag. See [ADR 0003](adr/0003-update-trust.md). | +| **Nothing elevated from where the user can write** | DNN Manager runs as Administrator: a program it starts, or a file it writes and reads back, in a folder any program of the user's could change would hand those programs Administrator rights. So what the user's account can change goes through a few choke points, held by tests ([ADR 0004](adr/0004-elevation-boundary.md)): settings and `DNNMANAGER_*` through `SettingRules`; every elevated start through `ProcessRunner` / `ElevatedStart` (with `TrustedPrograms` and `ChildEnvironment`); writes, extracts and deletes in folders others can write through `SafePath` / `SafeZip`; a site's XML through `SiteXml`; DNN Manager's own start through the launcher; terminals without administrator rights unless asked. Temporary files go to `PrivateTemp` (`%ProgramData%\DnnManager\temp`), the projects folder is kept to administrators and you (`ProjectsFolderGuard`), and editors start as the user (`Unelevated`). See [security.md](security.md#administrator-rights). | +| **Updates checked twice** | An update is checked against GitHub's SHA-256 when it is downloaded and again by the elevated helper right before it runs it, from a folder only administrators can change; releases are built by the release workflow on GitHub from their tag, with a build-provenance attestation, tried (installed and started), and published only once CI passed on it and a reviewer approved. See [ADR 0003](adr/0003-update-trust.md). | | **One reading of a SQL Server address** | Whether a database is the local container, on this PC or somewhere else decides what DNN Manager may drop. [`SqlServerAddress`](../src/DnnManager.Application/Abstractions/SqlServerAddress.cs) is the only place that reads an address (`tcp:`, ports, named instances, `[::1]`, LocalDB); [`SiteDatabases`](../src/DnnManager.Application/UseCases/SiteDatabases.cs) says which other IIS site uses a database, before one is dropped, replaced or taken over. | | **Adapters for GUI → app layer** | `GuiProgressReporter` (writes to the activity log) and `GuiUserPrompt` (modal dialogs) implement application interfaces, so use cases never know what drives them. | | **Runtime theming** | Colours live in `LightTheme` / `DarkTheme`; everything references them with `DynamicResource`, and `ThemeManager` swaps the dictionary (and the title bar's dark mode) live. | | **Reusable control styles** | Every control's look is a style in `Themes/Controls`, not set per page: a plain `` or `