diff --git a/Directory.Packages.props b/Directory.Packages.props index 680b22a..e243eba 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -73,6 +73,25 @@ ProxyJump both go through a loopback TCP bridge. See docs/adr/. --> + + + + + + + + + + diff --git a/DodoSSH.slnx b/DodoSSH.slnx index 46956a9..3a3beb6 100644 --- a/DodoSSH.slnx +++ b/DodoSSH.slnx @@ -16,6 +16,7 @@ + diff --git a/README.md b/README.md index 4a79c5f..f5cae30 100644 --- a/README.md +++ b/README.md @@ -40,15 +40,23 @@ The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/) ``` src/ - DodoSSH.Contracts DTOs shared with the client — the real API contract - DodoSSH.Crypto DSH1 envelope, AAD derivation, key wrapping - DodoSSH.Domain entities and invariants, no EF - DodoSSH.Infrastructure DbContext, configurations, migrations - DodoSSH.Api the host -tests/ one test project per source project -docs/adr/ architecture decision records + DodoSSH.Contracts DTOs shared with the client — the real API contract + DodoSSH.Crypto DSH1 envelope, AAD derivation, the key hierarchy + DodoSSH.Domain entities and invariants, no EF + DodoSSH.Infrastructure DbContext, configurations, migrations + DodoSSH.Api the server + DodoSSH.Client.Auth OIDC code+PKCE on a loopback redirect, and the key binding + DodoSSH.Client.Ssh connections, PTY shells, host key trust + DodoSSH.Client.Terminal the loopback data plane and credit-based flow control + DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit +tests/ one test project per source project +docs/adr/ architecture decision records ``` +Everything under `src/DodoSSH.Client.*` except `App` is deliberately free of Avalonia. That is the +seam that lets the SSH layer, the terminal's flow control and the OIDC flow be tested without a UI +toolkit or a browser engine — which is most of why they are testable at all. + ## Building Requires the .NET SDK pinned in [`global.json`](global.json) (10.0.x). @@ -89,10 +97,13 @@ off-Windows. - **M0 — foundation.** Repo structure, build conventions, CI, ADRs. *Done.* - **M1 — vertical slice.** OIDC login → enroll → create a host → open a shell. - *Backend done:* the DSH1 crypto core, the data model, sync push/pull for hosts, `/me`, and - enrollment with the identity-provider key binding. *Remaining:* the desktop client, and the two - spikes that gate it — the Linux WebView and SSH.NET's `window-change`. Both need a Linux and a - macOS machine, so neither has run yet. + *Server done:* the DSH1 crypto core, the data model, sync push/pull for hosts, `/me`, and + enrollment with the identity-provider key binding. + *Client done:* the key hierarchy, the OIDC flow with the key binding, SSH connections with host key + trust, the terminal data plane, and an Avalonia shell whose terminal works end to end against a real + `sshd`. + *Remaining:* the encrypted local cache and the sync client, which are what let the app read hosts + from the vault instead of a form. The client currently connects to a host you type in. - **M2 — full personal vault**, robust sync, relay. - **M3 — teams**, sharing, ACLs. - **M4 — hardening and ops**, packaging, self-hosting guide. diff --git a/docs/platform-flags.md b/docs/platform-flags.md index 5b0d6f7..42035f0 100644 --- a/docs/platform-flags.md +++ b/docs/platform-flags.md @@ -28,13 +28,37 @@ or notarization fails with an error that does not name the offending file. ## Desktop client -**The Avalonia WebView on Linux is unproven, and is the single largest risk in the plan.** The -official control uses WPE WebKit (`libwpewebkit-2.0`); the community `NativeWebView` uses WebKitGTK -(`libwebkit2gtk-4.1`), which is far more widely installed. Avalonia's own documentation -contradicts itself on whether offscreen rendering works there. *Unverified:* the spike must cover -Ubuntu on both Wayland and X11, Fedora KDE, and macOS 15. This is why the terminal sits behind -`ITerminalHost` — that abstraction is what preserves the option to swap backends, and it should -not be collapsed away for convenience. +**The WebView works on Windows.** `Avalonia.Controls.WebView` 12.0.1 (MIT, no licence key) hosts the +terminal page successfully: WebView2 launches, navigates to the loopback page, runs its JavaScript and +completes the WebSocket handshake. Verified by observing an established TCP connection from +`msedgewebview2` to the data plane port. + +**The Windows app manifest must declare a `supportedOS` list.** Without it the process reports a +downlevel Windows version and Avalonia's native control host fails outright — *"Unable to create child +window for native control host"* — so the WebView, and therefore the terminal, does not start at all. +`[STAThread]` on `Main` is equally mandatory: WebView2 checks the apartment state and refuses to +initialise on an MTA thread. + +**WebView2 spawns a process tree, not a process.** Around 35 processes were observed for one embedded +view. That is the concrete reason the design uses one WebView hosting N terminals rather than one per +tab: twenty tabs would mean twenty of those trees. + +**The Avalonia WebView on Linux remains unproven, and is still the largest risk in the plan.** The +package's own release notes say `NativeWebView` gained Linux support via a **WPE** backend +(`libwpewebkit-2.0`), which is much less widely installed than WebKitGTK — and it ships a separate +`NativeWebDialog` described as *"particularly useful for platforms like Linux where embedded WebView +controls might not be available"*, which is the vendor confirming the concern. *Unverified:* a spike +must cover Ubuntu on both Wayland and X11, Fedora KDE, and macOS 15. This is why the terminal sits +behind `ITerminalHost`; that seam should not be collapsed away for convenience. + +**`Avalonia.Diagnostics` has no 12.x release** (latest is 11.3.18), so the developer tools overlay is +unavailable on Avalonia 12. Development-only, so nothing ships differently — but debugging a layout +problem currently means reasoning rather than inspecting. + +**The xterm bundles are vendored, not built.** `@xterm/xterm` 6.0.0 with the fit and webgl addons, all +MIT, committed as UMD bundles under `WebAssets/vendor` and embedded as Avalonia resources. No npm or +esbuild step, so a clean clone builds with the .NET SDK alone. The cost is that upgrades are a manual +re-download; the licence and versions are recorded here so that stays visible. **SSH.NET's `window-change` is verified working** as of 2025.1.0 — resolved, not a flag. `ShellStream.ChangeWindowSize(columns, rows, width, height)` exists and the remote genuinely @@ -43,6 +67,12 @@ repeated resizes each take effect. The `IChannelSession` fallback is not needed. in place as a regression guard, because an upgrade that silently stopped sending the request would present as wrapped output only after a resize — easy to misattribute to the terminal emulator. +**`ShellStream.Write` buffers and requires an explicit `Flush`.** Without one a keystroke is accepted, +reported as written, and never reaches the remote — the terminal displays output perfectly and simply +stops responding to input. SSH.NET's own `WriteLine` flushes, which is why a spike that used it never +hit this. `SshNetShellSession.WriteAsync` now flushes per write; batching would be wrong anyway, since +a terminal has to put a keystroke on the wire immediately. + **`ShellStream` does not override `ReadAsync`.** The base `Stream` implementation therefore runs the blocking `Read` on a thread-pool thread, so every open session parks one thread for as long as it is idle. Fine for the handful of tabs M1 targets; revisit before advertising many concurrent diff --git a/src/DodoSSH.Client.App/App.axaml b/src/DodoSSH.Client.App/App.axaml new file mode 100644 index 0000000..aac2c81 --- /dev/null +++ b/src/DodoSSH.Client.App/App.axaml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/src/DodoSSH.Client.App/App.axaml.cs b/src/DodoSSH.Client.App/App.axaml.cs new file mode 100644 index 0000000..059079d --- /dev/null +++ b/src/DodoSSH.Client.App/App.axaml.cs @@ -0,0 +1,81 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using DodoSSH.Client.App.Terminal; +using DodoSSH.Client.App.ViewModels; +using DodoSSH.Client.App.Views; +using DodoSSH.Client.Ssh; +using DodoSSH.Client.Terminal; + +namespace DodoSSH.Client.App; + +/// +/// The Avalonia application. +/// +/// +/// Named DodoSshApp rather than the conventional App only because the assembly's root +/// namespace already ends in App, and a type whose name matches its namespace forces every +/// ambiguous reference to be fully qualified. +/// +internal sealed partial class DodoSshApp : Application +{ + /// + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + /// + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + Compose(desktop); + } + + base.OnFrameworkInitializationCompleted(); + } + + /// + /// Composed by hand rather than through a container. The graph is four objects deep, and an + /// indirection to read through would buy nothing at this size. + /// + /// The workspace is a local captured by the closures below rather than a field, so this type does + /// not own a disposable it has no good place to dispose — an Avalonia Application has no + /// disposal hook of its own. + /// + /// + private static void Compose(IClassicDesktopStyleApplicationLifetime desktop) + { + var knownHosts = new InMemoryKnownHostStore(); + + var workspace = new TerminalWorkspace( + new AvaloniaTerminalAssetProvider(), + new SshNetConnectionFactory(knownHosts), + TimeProvider.System); + + workspace.Start(); + + desktop.MainWindow = new MainWindow + { + DataContext = new MainWindowViewModel(workspace, knownHosts), + }; + + var shuttingDown = false; + + // Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening + // socket, and blocking the UI thread on their disposal is how an application comes to take + // several seconds to close — or deadlocks, if any of that disposal needs the UI thread. + desktop.ShutdownRequested += async (_, e) => + { + if (shuttingDown) + { + return; + } + + shuttingDown = true; + e.Cancel = true; + + await workspace.DisposeAsync().ConfigureAwait(true); + + desktop.Shutdown(); + }; + } +} diff --git a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj new file mode 100644 index 0000000..269d39c --- /dev/null +++ b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj @@ -0,0 +1,41 @@ + + + + WinExe + app.manifest + true + + + false + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.App/Program.cs b/src/DodoSSH.Client.App/Program.cs new file mode 100644 index 0000000..bfad2f0 --- /dev/null +++ b/src/DodoSSH.Client.App/Program.cs @@ -0,0 +1,24 @@ +using Avalonia; + +namespace DodoSSH.Client.App; + +internal static class Program +{ + /// + /// Entry point. + /// + /// + /// STAThread is required, not decorative: WebView2 checks the apartment state and refuses + /// to initialise on an MTA thread. Without it the terminal is simply blank on Windows. + /// + [STAThread] + public static void Main(string[] args) => + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + /// Used by the designer as well as by . + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/src/DodoSSH.Client.App/Terminal/AvaloniaTerminalAssetProvider.cs b/src/DodoSSH.Client.App/Terminal/AvaloniaTerminalAssetProvider.cs new file mode 100644 index 0000000..85d8a87 --- /dev/null +++ b/src/DodoSSH.Client.App/Terminal/AvaloniaTerminalAssetProvider.cs @@ -0,0 +1,45 @@ +using Avalonia.Platform; +using DodoSSH.Client.Terminal; + +namespace DodoSSH.Client.App.Terminal; + +/// +/// Serves the renderer's files from the assembly's embedded resources. +/// +/// +/// Read once at startup and cached. The files are a few hundred kilobytes in total, dominated by the +/// xterm bundle, and a terminal that stalled on a resource stream read while output was arriving +/// would be a strange way to save a rounding error of memory. +/// +internal sealed class AvaloniaTerminalAssetProvider : ITerminalAssetProvider +{ + private const string ResourceRoot = "avares://DodoSSH.Client.App/WebAssets"; + + private static readonly (string Path, string File, string ContentType)[] Files = + [ + (TerminalDataPlane.PagePath, "terminal.html", "text/html; charset=utf-8"), + ("/terminal.js", "terminal.js", "text/javascript; charset=utf-8"), + ("/terminal.css", "terminal.css", "text/css; charset=utf-8"), + ("/vendor/xterm.js", "vendor/xterm.js", "text/javascript; charset=utf-8"), + ("/vendor/xterm.css", "vendor/xterm.css", "text/css; charset=utf-8"), + ("/vendor/addon-fit.js", "vendor/addon-fit.js", "text/javascript; charset=utf-8"), + ("/vendor/addon-webgl.js", "vendor/addon-webgl.js", "text/javascript; charset=utf-8"), + ]; + + private readonly Dictionary assets = new(StringComparer.Ordinal); + + internal AvaloniaTerminalAssetProvider() + { + foreach (var (path, file, contentType) in Files) + { + using var stream = AssetLoader.Open(new Uri($"{ResourceRoot}/{file}", UriKind.Absolute)); + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + + assets[path] = new TerminalAsset(contentType, buffer.ToArray()); + } + } + + /// + public TerminalAsset? Find(string path) => assets.GetValueOrDefault(path); +} diff --git a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..e2cb15b --- /dev/null +++ b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,146 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DodoSSH.Client.App.Terminal; +using DodoSSH.Client.Ssh; +using DodoSSH.Client.Terminal; + +namespace DodoSSH.Client.App.ViewModels; + +/// +/// The shell: connect to a host, and surface host key trust decisions. +/// +/// +/// +/// Hosts are typed in directly for now. Reading them from the encrypted vault needs the local cache +/// and the sync client, which are the next pieces; this exists to prove the terminal path end to end +/// and is deliberately obvious about being temporary rather than looking like a finished feature. +/// +/// +/// The two host key states are modelled separately and behave differently, which is the point. An +/// unknown host offers a Trust button. A changed key offers nothing — see +/// for why there is no "continue anyway" here. +/// +/// +internal sealed partial class MainWindowViewModel( + TerminalWorkspace workspace, + IKnownHostStore knownHosts) : ObservableObject +{ + [ObservableProperty] + private string host = "127.0.0.1"; + + [ObservableProperty] + private int port = 22; + + [ObservableProperty] + private string username = string.Empty; + + [ObservableProperty] + private string password = string.Empty; + + [ObservableProperty] + private string status = "Enter a host and connect."; + + [ObservableProperty] + private bool isConnecting; + + /// The key awaiting the user's decision, or null when there is none. + [ObservableProperty] + private HostKeyPresentation? pendingHostKey; + + /// Set when a pinned key changed, which is a dead end rather than a prompt. + [ObservableProperty] + private string? hostKeyMismatch; + + /// Where the embedded browser should navigate. + public Uri TerminalPageUrl => workspace.PageUrl; + + /// Whether the trust prompt should be visible. + public bool HasPendingHostKey => PendingHostKey is not null; + + /// Whether the mismatch banner should be visible. + public bool HasHostKeyMismatch => HostKeyMismatch is not null; + + [RelayCommand] + private async Task ConnectAsync(CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(Username)) + { + Status = "A username is required."; + return; + } + + IsConnecting = true; + PendingHostKey = null; + HostKeyMismatch = null; + Status = $"Connecting to {Host}:{Port}…"; + + try + { + // The renderer has to be attached first: the transport drops frames when nothing is + // connected, so a session opened earlier would lose its SessionOpened frame and then + // stream output at a terminal that was never created. + await workspace.WaitForRendererAsync().ConfigureAwait(true); + + var request = new SshConnectionRequest( + Host, + Port, + Username, + new SshPasswordCredential(Password)); + + await workspace + .OpenSessionAsync(request, TerminalSize.Default, cancellationToken) + .ConfigureAwait(true); + + Status = $"Connected to {Host}:{Port}."; + } + catch (SshHostKeyUnknownException exception) + { + // First contact. The user has to decide, and they need the fingerprint to do it. + PendingHostKey = exception.Presentation; + Status = "This host has not been seen before."; + } + catch (SshHostKeyMismatchException exception) + { + HostKeyMismatch = exception.Message; + Status = "The host key has changed. The connection was refused."; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + Status = exception.Message; + } + finally + { + IsConnecting = false; + } + } + + /// Pins the offered key and retries. + [RelayCommand] + private async Task TrustHostKeyAsync(CancellationToken cancellationToken) + { + if (PendingHostKey is not { } presentation) + { + return; + } + + await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true); + + PendingHostKey = null; + + await ConnectAsync(cancellationToken).ConfigureAwait(true); + } + + /// Dismisses the trust prompt without pinning anything. + [RelayCommand] + private void RejectHostKey() + { + PendingHostKey = null; + Status = "The host key was not trusted, so nothing was connected."; + } + + partial void OnPendingHostKeyChanged(HostKeyPresentation? value) => + OnPropertyChanged(nameof(HasPendingHostKey)); + + partial void OnHostKeyMismatchChanged(string? value) => + OnPropertyChanged(nameof(HasHostKeyMismatch)); +} diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml new file mode 100644 index 0000000..dc6a5c4 --- /dev/null +++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml @@ -0,0 +1,75 @@ + + + + + + + + + + + +