Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d5ff9f23a | ||
|
|
8209f15741 |
@@ -297,7 +297,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. |
|
> | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. |
|
||||||
> | A **Port forward** button on the host header | Omitted. The feature does not exist; see the FORWARDING rows earlier in this document. |
|
> | A **Port forward** button on the host header | Omitted. The feature does not exist; see the FORWARDING rows earlier in this document. |
|
||||||
> | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". |
|
> | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". |
|
||||||
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | Omitted, and recorded rather than silently dropped. None of the three is reachable from this shell today: `SshNetConnection` holds the negotiated algorithms but nothing reads them off `ISshConnection`; `HostKeyPresentation` exists only for the trust questions a connection asks on the way in, not for a session already open; and a key's display name is never threaded past the connect call into anything the session keeps. What is real and bound is CONNECTED plus a dot, the session's own elapsed timer, and — terminal surface only — `UTF-8`, worded as a fact about this client's own renderer rather than a claim the remote agreed to. Candidates for future plumbing, not a defect closed here. |
|
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. |
|
||||||
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
|
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
|
||||||
> | The S3/Buckets screen | **Did not get the session shell in v5b.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sat inside the new tab row/header/status bar/sidebar; the S3 usage was unchanged at the time. **v5c gives it the shell's own look without the machinery** — a 26-pixel padded, bordered, radius-12 container and nothing past that, since a bucket has no tab to close, no host to head a card with and no pin for a sidebar to show; see the v5c section, below. |
|
> | The S3/Buckets screen | **Did not get the session shell in v5b.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sat inside the new tab row/header/status bar/sidebar; the S3 usage was unchanged at the time. **v5c gives it the shell's own look without the machinery** — a 26-pixel padded, bordered, radius-12 container and nothing past that, since a bucket has no tab to close, no host to head a card with and no pin for a sidebar to show; see the v5c section, below. |
|
||||||
> | No pins destination in the design at all | **Kept anyway.** The rail still carries Pins — `KnownHostsScreen` — because the mock has no screen for approved host keys and this application's has to stay reachable. |
|
> | No pins destination in the design at all | **Kept anyway.** The rail still carries Pins — `KnownHostsScreen` — because the mock has no screen for approved host keys and this application's has to stay reachable. |
|
||||||
@@ -537,7 +537,7 @@ caption buttons and window title drawn on top of the application's own — two s
|
|||||||
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
|
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
|
||||||
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
|
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
|
||||||
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
|
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
|
||||||
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the session shell's own host header shows the account and endpoint actually dialled (v3–v4: the tab strip did; v5b moved the fact to `SessionHeader`, off `MainWindowViewModel.SessionAddress`, when the strip was retired). |
|
| Pane header `aes256-gcm` | client-ssh | **No longer a miss — the plumbing this row used to lack now exists.** `ISshConnection.Cipher` and `TerminalWorkspace.GetSessionFacts` were built for the v5b session shell's status bar; see that row in the v5b section, above. | Still not on a pane header — this pre-v5b element does not exist as its own piece of chrome any more. The fact it wanted to show is drawn instead where v5b moved it: the status bar's `SessionCipher`, beside CONNECTED. The session shell's host header separately shows the account and endpoint actually dialled (v3–v4: the tab strip did; v5b moved that fact to `SessionHeader`, off `MainWindowViewModel.SessionAddress`, when the strip was retired). |
|
||||||
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
|
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
|
||||||
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
|
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
|
||||||
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
|
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
|
||||||
|
|||||||
@@ -8,36 +8,56 @@
|
|||||||
<!--
|
<!--
|
||||||
── v5b's session shell status bar ───────────────────────────────────────────────────────────────────────
|
── v5b's session shell status bar ───────────────────────────────────────────────────────────────────────
|
||||||
37px, DeepChrome, a 1px top border — the foot of the bordered container both the terminal and the SFTP
|
37px, DeepChrome, a 1px top border — the foot of the bordered container both the terminal and the SFTP
|
||||||
surface share. The design's own row also carries a negotiated cipher, a host-key algorithm and a
|
surface share. The design's own row also carries a negotiated cipher and a host-key-plus-identity run
|
||||||
key/credential name; none of the three is reachable from this shell today — see the wave B report this
|
(`ed25519 · acme-deploy-key`, clickable to a pin-details modal); both are now real and bound, plumbed all
|
||||||
control's history carries, and design-notes/v5b-fidelity-notes.md's own decided deviation — so none of
|
the way from SSH.NET through TerminalWorkspace.GetSessionFacts and TransfersViewModel to
|
||||||
them is drawn, omitted rather than faked, per this project's honesty rule.
|
MainWindowViewModel's own surface-aware properties — see SessionCipher, SessionHostKeyAlgorithm,
|
||||||
|
SessionIdentityLabel and the composed SessionIdentityText.
|
||||||
|
|
||||||
|
Three honest deviations from the mock. The algorithm prints exactly as negotiated — "ssh-ed25519", not
|
||||||
|
the design's shortened "ed25519" — because trimming it would be a cosmetic edit to a string this client
|
||||||
|
did not choose. The run is plain text, not a button: this application has no pin-details modal for a
|
||||||
|
session that is already open, and drawing a click target for a screen that does not exist would be the
|
||||||
|
fabrication this project's honesty rule forbids, not an honest omission of one. And a typed-password
|
||||||
|
session — nothing filed in the keychain to name — shows the algorithm alone, with no " · " after it,
|
||||||
|
because there is no item behind the dot.
|
||||||
|
|
||||||
What is real and bound: the CONNECTED word and dot, off IsSessionConnected, shown only while there is a
|
What is real and bound: the CONNECTED word and dot, off IsSessionConnected, shown only while there is a
|
||||||
session/host context to report on at all — SessionAddress null means nothing here has anything to say,
|
session/host context to report on at all — SessionAddress null means nothing here has anything to say,
|
||||||
the same state the header answers with its own empty-state sentence; the elapsed timer, off
|
the same state the header answers with its own empty-state sentence; the cipher and the host-key/identity
|
||||||
SessionElapsedText, which is null and therefore absent whenever there is nothing timed; and, only on the
|
run, each collapsed rather than shown empty or fabricated when the fact behind it is not there — a bucket
|
||||||
terminal surface — see <see cref="ShowsEncoding"/> — "UTF-8", which is a true fact about this client's own
|
connection has neither, and a session whose facts could not be read back at the instant they were asked
|
||||||
renderer and write path (see TerminalWorkspace's terminal.js and SshShellSessionExtensions.WriteTextAsync)
|
for has neither either; the elapsed timer, off SessionElapsedText, which is null and therefore absent
|
||||||
rather than a negotiated session parameter, and is worded plainly rather than as a claim the remote agreed
|
whenever there is nothing timed; and, only on the terminal surface — see <see cref="ShowsEncoding"/> —
|
||||||
to it.
|
"UTF-8", which is a true fact about this client's own renderer and write path (see TerminalWorkspace's
|
||||||
|
terminal.js and SshShellSessionExtensions.WriteTextAsync) rather than a negotiated session parameter, and
|
||||||
|
is worded plainly rather than as a claim the remote agreed to it.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Border Height="37" Background="{StaticResource DeepChrome}"
|
<Border Height="37" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="24,0">
|
<Grid ColumnDefinitions="*,Auto" Margin="24,0">
|
||||||
|
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
|
||||||
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
|
||||||
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
|
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
VerticalAlignment="Center" />
|
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
|
||||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||||
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
|
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
|
||||||
IsVisible="{Binding IsSessionConnected}" />
|
IsVisible="{Binding IsSessionConnected}" />
|
||||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||||
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
|
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
|
||||||
IsVisible="{Binding !IsSessionConnected}" />
|
IsVisible="{Binding !IsSessionConnected}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||||
|
VerticalAlignment="Center" Text="{Binding SessionCipher}"
|
||||||
|
IsVisible="{Binding SessionCipher, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||||
|
VerticalAlignment="Center" Text="{Binding SessionIdentityText}"
|
||||||
|
IsVisible="{Binding SessionIdentityText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ using Avalonia.Controls;
|
|||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The v5b session shell's status bar: CONNECTED and its dot, the elapsed timer, and — only where it is true
|
/// The v5b session shell's status bar: CONNECTED and its dot, the negotiated cipher, the host key's algorithm
|
||||||
/// — UTF-8. See the remark at the top of SessionStatusBar.axaml.
|
/// and — where one authenticated — the identity that did, the elapsed timer, and — only where it is true —
|
||||||
|
/// UTF-8. See the remark at the top of SessionStatusBar.axaml.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed partial class SessionStatusBar : UserControl
|
internal sealed partial class SessionStatusBar : UserControl
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3400,7 +3400,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// No tab was opened for this attempt, which means the user closed the connecting tab while the
|
// No tab was opened for this attempt, which means the user closed the connecting tab while the
|
||||||
// handshake was still running. The session is real and has to be adopted rather than dropped:
|
// handshake was still running. The session is real and has to be adopted rather than dropped:
|
||||||
// dropping it would leave a shell running with nothing in the window naming it.
|
// dropping it would leave a shell running with nothing in the window naming it.
|
||||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address) { StartedAt = clock.GetUtcNow() };
|
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
||||||
|
{
|
||||||
|
StartedAt = clock.GetUtcNow(),
|
||||||
|
Cipher = NullIfEmpty(e.Cipher),
|
||||||
|
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
||||||
|
IdentityLabel = e.IdentityLabel,
|
||||||
|
};
|
||||||
AdoptTab(adopted);
|
AdoptTab(adopted);
|
||||||
RefreshConnectedHosts();
|
RefreshConnectedHosts();
|
||||||
return;
|
return;
|
||||||
@@ -3411,6 +3417,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// From this moment, not from when the tab first appeared — connecting is not open, and the session
|
// From this moment, not from when the tab first appeared — connecting is not open, and the session
|
||||||
// shell's elapsed timer is about a shell that is actually running.
|
// shell's elapsed timer is about a shell that is actually running.
|
||||||
tab.StartedAt = clock.GetUtcNow();
|
tab.StartedAt = clock.GetUtcNow();
|
||||||
|
tab.Cipher = NullIfEmpty(e.Cipher);
|
||||||
|
tab.HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm);
|
||||||
|
tab.IdentityLabel = e.IdentityLabel;
|
||||||
|
|
||||||
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
|
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
|
||||||
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
|
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
|
||||||
@@ -3914,6 +3923,69 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The negotiated server-to-client cipher the status bar shows beside CONNECTED, or null when neither
|
||||||
|
/// surface has a session — or when the one it has has nothing to report.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One property reading whichever surface is showing, for the same reason <see cref="SessionAddress"/>
|
||||||
|
/// is: <c>SessionStatusBar.axaml</c> is the same markup on both surfaces. The terminal's is
|
||||||
|
/// <see cref="TerminalTabViewModel.Cipher"/>; SFTP's is <see cref="TransfersViewModel.ConnectedCipher"/>.
|
||||||
|
/// </remarks>
|
||||||
|
internal string? SessionCipher => Surface switch
|
||||||
|
{
|
||||||
|
ShellSurface.Terminal => NullIfEmpty(SelectedTab?.Cipher),
|
||||||
|
_ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedCipher),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c> — printed as negotiated, not shortened.</summary>
|
||||||
|
/// <remarks>See <see cref="SessionCipher"/>; resolved the same way, off <see cref="TerminalTabViewModel.HostKeyAlgorithm"/> or <see cref="TransfersViewModel.ConnectedHostKeyAlgorithm"/>.</remarks>
|
||||||
|
internal string? SessionHostKeyAlgorithm => Surface switch
|
||||||
|
{
|
||||||
|
ShellSurface.Terminal => NullIfEmpty(SelectedTab?.HostKeyAlgorithm),
|
||||||
|
_ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedHostKeyAlgorithm),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The display name of the key or credential that authenticated, or null when a typed password did or
|
||||||
|
/// nothing is connected.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>See <see cref="SessionCipher"/>; resolved the same way, off <see cref="TerminalTabViewModel.IdentityLabel"/> or <see cref="TransfersViewModel.ConnectedIdentityLabel"/>.</remarks>
|
||||||
|
internal string? SessionIdentityLabel => Surface switch
|
||||||
|
{
|
||||||
|
ShellSurface.Terminal => SelectedTab?.IdentityLabel,
|
||||||
|
_ when IsTransfersShowing => Transfers.ConnectedIdentityLabel,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The status bar's one run of text for the host key and the identity that authenticated — the design's
|
||||||
|
/// <c>ed25519 · acme-deploy-key</c> — or null while <see cref="SessionHostKeyAlgorithm"/> is.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Composed here rather than in the view, so <c>SessionStatusBar.axaml</c> binds one <c>TextBlock</c> to
|
||||||
|
/// one string instead of assembling a separator between two bindings that can each be absent on their own.
|
||||||
|
/// The algorithm prints exactly as negotiated — <c>ssh-ed25519</c>, not the design's shortened
|
||||||
|
/// <c>ed25519</c> — because trimming it would be a cosmetic claim about a string this shell has no
|
||||||
|
/// business editing. A typed-password session has no item to name, so it shows the algorithm alone with
|
||||||
|
/// no <c>·</c> — there being nothing after the dot would be a punctuation mark standing in for the fact
|
||||||
|
/// that was never real.
|
||||||
|
/// </remarks>
|
||||||
|
internal string? SessionIdentityText => SessionHostKeyAlgorithm is { } algorithm
|
||||||
|
? SessionIdentityLabel is { } label ? $"{algorithm} · {label}" : algorithm
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>Null for an empty string, unchanged otherwise.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="ISshConnection.Cipher"/> and <see cref="HostKeyPresentation.Algorithm"/> are non-nullable
|
||||||
|
/// strings that this shell nonetheless treats as absent when empty — a session whose facts genuinely
|
||||||
|
/// could not be read back (see <c>TerminalWorkspace.GetSessionFacts</c>) hands over <see cref="string.Empty"/>
|
||||||
|
/// rather than null, and the status bar's collapse bindings only know how to ask about null.
|
||||||
|
/// </remarks>
|
||||||
|
private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
|
||||||
|
|
||||||
/// <summary>Whether the session the header and status bar are describing is actually open.</summary>
|
/// <summary>Whether the session the header and status bar are describing is actually open.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Not the same question as <see cref="SessionAddress"/> being non-null on the terminal surface: a tab
|
/// Not the same question as <see cref="SessionAddress"/> being non-null on the terminal surface: a tab
|
||||||
@@ -3981,6 +4053,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
OnPropertyChanged(nameof(SessionAddress));
|
OnPropertyChanged(nameof(SessionAddress));
|
||||||
OnPropertyChanged(nameof(IsSessionConnected));
|
OnPropertyChanged(nameof(IsSessionConnected));
|
||||||
OnPropertyChanged(nameof(SessionElapsedText));
|
OnPropertyChanged(nameof(SessionElapsedText));
|
||||||
|
OnPropertyChanged(nameof(SessionCipher));
|
||||||
|
OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
|
||||||
|
OnPropertyChanged(nameof(SessionIdentityLabel));
|
||||||
|
OnPropertyChanged(nameof(SessionIdentityText));
|
||||||
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
|
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3996,7 +4072,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
|
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
|
||||||
or nameof(TransfersViewModel.IsConnected)
|
or nameof(TransfersViewModel.IsConnected)
|
||||||
or nameof(TransfersViewModel.Remote)
|
or nameof(TransfersViewModel.Remote)
|
||||||
or nameof(TransfersViewModel.ConnectedTo))
|
or nameof(TransfersViewModel.ConnectedTo)
|
||||||
|
or nameof(TransfersViewModel.ConnectedCipher)
|
||||||
|
or nameof(TransfersViewModel.ConnectedHostKeyAlgorithm)
|
||||||
|
or nameof(TransfersViewModel.ConnectedIdentityLabel))
|
||||||
{
|
{
|
||||||
RaiseSessionState();
|
RaiseSessionState();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,11 @@ internal enum TerminalTabState
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A tab is a session id and two strings. It holds no terminal and owns nothing: the pane, its scrollback
|
/// A tab is a session id and a handful of strings — the address, and now the three connection facts the
|
||||||
/// and the shell behind it all live in the renderer and in <c>TerminalWorkspace</c>, and selecting a tab is
|
/// status bar draws beside CONNECTED. It holds no terminal and owns nothing: the pane, its scrollback and the
|
||||||
/// one frame telling the page which pane to show. That is what makes tabs cheap here — the expensive object
|
/// shell behind it all live in the renderer and in <c>TerminalWorkspace</c>, and selecting a tab is one frame
|
||||||
/// is the WebView, and there is one of those however many tabs are open.
|
/// telling the page which pane to show. That is what makes tabs cheap here — the expensive object is the
|
||||||
|
/// WebView, and there is one of those however many tabs are open.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>A tab starts before its session does.</b> Connecting is a network round trip that can take as long as
|
/// <b>A tab starts before its session does.</b> Connecting is a network round trip that can take as long as
|
||||||
@@ -102,6 +103,31 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal DateTimeOffset? StartedAt { get; set; }
|
internal DateTimeOffset? StartedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The negotiated server-to-client cipher, for the status bar — or null while there is no session.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Set alongside <see cref="StartedAt"/>, from the same event, and kept for the same reason: a dead tab's
|
||||||
|
/// pane still holds the scrollback of a session that really did negotiate this cipher, and clearing the
|
||||||
|
/// fact when the shell ends would not make it less true of what is on screen.
|
||||||
|
/// </remarks>
|
||||||
|
internal string? Cipher { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c> — or null while there is no session.</summary>
|
||||||
|
/// <remarks>See <see cref="Cipher"/>; set and kept the same way, for the same reason.</remarks>
|
||||||
|
internal string? HostKeyAlgorithm { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The display name of the key or credential that authenticated, or null when a typed password did, or
|
||||||
|
/// null while there is no session.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Three different reasons collapse to the same null, and that is deliberate: nothing downstream needs to
|
||||||
|
/// tell a session with no identity to name apart from a tab that has not opened one yet — both mean the
|
||||||
|
/// status bar shows the host-key algorithm alone, with no ` · name` after it.
|
||||||
|
/// </remarks>
|
||||||
|
internal string? IdentityLabel { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc cref="TerminalTabState" />
|
/// <inheritdoc cref="TerminalTabState" />
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private TerminalTabState state;
|
private TerminalTabState state;
|
||||||
|
|||||||
@@ -606,6 +606,37 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? connectedTo;
|
private string? connectedTo;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The negotiated server-to-client cipher for the open SFTP session, for the v5b status bar — or null
|
||||||
|
/// while nothing is connected, or while what is connected is a bucket rather than a host.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A bucket is <c>IRemoteFileStore</c> with no SSH underneath it at all, so it has no cipher, no host key
|
||||||
|
/// and no identity to name — <see cref="OpenBucketAsync"/> leaves all three null rather than each reading
|
||||||
|
/// as "not yet known", which is the meaning null already carries for a host that has not connected yet.
|
||||||
|
/// Read off the concrete <c>ISftpSession</c> at connect time, the same moment <see cref="ConnectedTo"/> is
|
||||||
|
/// set, because <see cref="session"/> itself is typed as <c>IRemoteFileStore</c> and does not carry it.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? connectedCipher;
|
||||||
|
|
||||||
|
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>. See <see cref="ConnectedCipher"/>.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? connectedHostKeyAlgorithm;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The display name of the key or credential that authenticated, or null when a typed password did, or
|
||||||
|
/// null while nothing is connected.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Threaded from <see cref="VaultViewModel.TryBuildConnectionRequest"/>'s own out parameter rather than
|
||||||
|
/// re-resolved here: the label names a keychain item this screen has no authentication ladder of its own
|
||||||
|
/// to climb, and a second lookup would be a second place for a stale binding to answer differently than
|
||||||
|
/// the one that actually authenticated.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? connectedIdentityLabel;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private HostKeyPresentation? pendingHostKey;
|
private HostKeyPresentation? pendingHostKey;
|
||||||
|
|
||||||
@@ -874,6 +905,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
ConnectedTo = string.Create(
|
ConnectedTo = string.Create(
|
||||||
CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");
|
CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");
|
||||||
|
|
||||||
|
// No SSH underneath a bucket, so none of the three has an honest value — see
|
||||||
|
// ConnectedCipher's own remark.
|
||||||
|
ConnectedCipher = null;
|
||||||
|
ConnectedHostKeyAlgorithm = null;
|
||||||
|
ConnectedIdentityLabel = null;
|
||||||
|
|
||||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||||
|
|
||||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||||
@@ -891,7 +928,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!open.TryBuildConnectionRequest(row.Host, TypedPassword, out var request, out var refusal))
|
if (!open.TryBuildConnectionRequest(
|
||||||
|
row.Host, TypedPassword, out var request, out var identityLabel, out var refusal))
|
||||||
{
|
{
|
||||||
Status = refusal;
|
Status = refusal;
|
||||||
return;
|
return;
|
||||||
@@ -906,9 +944,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
{
|
{
|
||||||
await CloseSessionAsync().ConfigureAwait(true);
|
await CloseSessionAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
|
ISftpSession opened;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
session = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
|
opened = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (SshHostKeyUnknownException exception)
|
catch (SshHostKeyUnknownException exception)
|
||||||
{
|
{
|
||||||
@@ -926,17 +966,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session = opened;
|
||||||
TypedPassword = string.Empty;
|
TypedPassword = string.Empty;
|
||||||
IsConnected = true;
|
MarkHostConnected(opened, request, row, identityLabel);
|
||||||
ConnectedTo = string.Create(
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
$"{request.Username}@{request.Host}:{request.Port}");
|
|
||||||
|
|
||||||
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second
|
|
||||||
// login as far as the remote's own auth.log is concerned, so a log of ours that omitted it
|
|
||||||
// would disagree with the host's — and anybody comparing the two would be right to believe
|
|
||||||
// the host.
|
|
||||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
|
||||||
|
|
||||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
@@ -944,6 +976,33 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
}).ConfigureAwait(true);
|
}).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records that a host connection just succeeded: the address, the negotiated facts, and the ticket the
|
||||||
|
/// disconnect log closes out later.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Split out of <see cref="ConnectToHostAsync"/> for length rather than for reuse — <see cref="OpenBucketAsync"/>
|
||||||
|
/// sets the same four properties its own way, with no SSH underneath to read the last three off. Reads
|
||||||
|
/// the cipher and host key off <paramref name="opened"/> rather than <see cref="session"/>, which is typed
|
||||||
|
/// as <c>IRemoteFileStore</c> and does not carry either.
|
||||||
|
/// </remarks>
|
||||||
|
private void MarkHostConnected(
|
||||||
|
ISftpSession opened, SshConnectionRequest request, HostRowViewModel row, string? identityLabel)
|
||||||
|
{
|
||||||
|
IsConnected = true;
|
||||||
|
ConnectedTo = string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{request.Username}@{request.Host}:{request.Port}");
|
||||||
|
ConnectedCipher = opened.Cipher;
|
||||||
|
ConnectedHostKeyAlgorithm = opened.HostKey.Algorithm;
|
||||||
|
ConnectedIdentityLabel = identityLabel;
|
||||||
|
|
||||||
|
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second login as
|
||||||
|
// far as the remote's own auth.log is concerned, so a log of ours that omitted it would disagree with
|
||||||
|
// the host's — and anybody comparing the two would be right to believe the host.
|
||||||
|
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Closes the file-transfer session.
|
/// Closes the file-transfer session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1713,6 +1772,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
IsConnected = false;
|
IsConnected = false;
|
||||||
ConnectedTo = null;
|
ConnectedTo = null;
|
||||||
|
ConnectedCipher = null;
|
||||||
|
ConnectedHostKeyAlgorithm = null;
|
||||||
|
ConnectedIdentityLabel = null;
|
||||||
RemotePath = string.Empty;
|
RemotePath = string.Empty;
|
||||||
RemoteEntries.Clear();
|
RemoteEntries.Clear();
|
||||||
RemoteTrail.Clear();
|
RemoteTrail.Clear();
|
||||||
|
|||||||
@@ -888,11 +888,19 @@ internal static class ItemBadge
|
|||||||
/// <param name="sessionId">Identifies the session to the renderer and to the workspace.</param>
|
/// <param name="sessionId">Identifies the session to the renderer and to the workspace.</param>
|
||||||
/// <param name="label">The host's name, as the vault has it.</param>
|
/// <param name="label">The host's name, as the vault has it.</param>
|
||||||
/// <param name="address">The account and endpoint actually dialled.</param>
|
/// <param name="address">The account and endpoint actually dialled.</param>
|
||||||
|
/// <param name="cipher">The negotiated server-to-client cipher; see <see cref="ISshConnection.Cipher"/>.</param>
|
||||||
|
/// <param name="hostKeyAlgorithm">The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>.</param>
|
||||||
|
/// <param name="identityLabel">
|
||||||
|
/// The display name of the key or credential that authenticated, or null when a typed password did.
|
||||||
|
/// </param>
|
||||||
internal sealed class TerminalSessionEventArgs(
|
internal sealed class TerminalSessionEventArgs(
|
||||||
Guid attemptId,
|
Guid attemptId,
|
||||||
uint sessionId,
|
uint sessionId,
|
||||||
string label,
|
string label,
|
||||||
string address) : EventArgs
|
string address,
|
||||||
|
string cipher,
|
||||||
|
string hostKeyAlgorithm,
|
||||||
|
string? identityLabel) : EventArgs
|
||||||
{
|
{
|
||||||
/// <summary>Which attempt this session came out of.</summary>
|
/// <summary>Which attempt this session came out of.</summary>
|
||||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
||||||
@@ -903,6 +911,12 @@ internal sealed class TerminalSessionEventArgs(
|
|||||||
internal string Label { get; } = label;
|
internal string Label { get; } = label;
|
||||||
|
|
||||||
internal string Address { get; } = address;
|
internal string Address { get; } = address;
|
||||||
|
|
||||||
|
internal string Cipher { get; } = cipher;
|
||||||
|
|
||||||
|
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
|
||||||
|
|
||||||
|
internal string? IdentityLabel { get; } = identityLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
|
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
|
||||||
@@ -11085,6 +11099,13 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
Status = $"Connected to {target.Label}.";
|
Status = $"Connected to {target.Label}.";
|
||||||
|
|
||||||
|
// The session the workspace just opened is the only place the negotiated cipher and host-key
|
||||||
|
// algorithm live — an SshConnectionRequest asks for neither and gets no say in either — so they are
|
||||||
|
// read back here rather than carried forward from somewhere upstream that never had them. Absent only
|
||||||
|
// for a session that has already ended in the instant between opening it and asking, which the empty
|
||||||
|
// string collapses to on the status bar exactly as a session with nothing to report would.
|
||||||
|
var facts = workspace.GetSessionFacts(sessionId);
|
||||||
|
|
||||||
// Only now, and only on success. The page's own term.focus() focuses the textarea inside the
|
// Only now, and only on success. The page's own term.focus() focuses the textarea inside the
|
||||||
// document, which does nothing while the window's keyboard focus is still on the Connect button — so
|
// document, which does nothing while the window's keyboard focus is still on the Connect button — so
|
||||||
// without this the first keystrokes of the session go to the shell's UI instead of the remote shell.
|
// without this the first keystrokes of the session go to the shell's UI instead of the remote shell.
|
||||||
@@ -11094,7 +11115,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
attempt.AttemptId,
|
attempt.AttemptId,
|
||||||
sessionId,
|
sessionId,
|
||||||
target.Label,
|
target.Label,
|
||||||
Dialled(target, authentication)));
|
Dialled(target, authentication),
|
||||||
|
facts?.Cipher ?? string.Empty,
|
||||||
|
facts?.HostKeyAlgorithm ?? string.Empty,
|
||||||
|
authentication.IdentityLabel));
|
||||||
|
|
||||||
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
||||||
// for must not wait on a vault write to appear.
|
// for must not wait on a vault write to appear.
|
||||||
@@ -11279,13 +11303,19 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Username">The account to log in as, after any credential has had its say.</param>
|
/// <param name="Username">The account to log in as, after any credential has had its say.</param>
|
||||||
/// <param name="Credential">What proves it.</param>
|
/// <param name="Credential">What proves it.</param>
|
||||||
|
/// <param name="IdentityLabel">
|
||||||
|
/// The display name of the keychain item that authenticates — a key's or a credential's own
|
||||||
|
/// <c>Label</c> — or null when the password was typed rather than filed. Carried alongside the credential
|
||||||
|
/// so the session shell's status bar can name what actually authenticated without a second lookup back
|
||||||
|
/// into <see cref="Keys"/> or <see cref="Credentials"/> once the connection has moved on.
|
||||||
|
/// </param>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The two travel together because a credential can change both. Returning only the secret and reading the
|
/// The two travel together because a credential can change both. Returning only the secret and reading the
|
||||||
/// username off the host separately is what the connect path used to do, and it would have sent a stored
|
/// username off the host separately is what the connect path used to do, and it would have sent a stored
|
||||||
/// credential's password under the host's username — which is the one combination that is wrong in a way
|
/// credential's password under the host's username — which is the one combination that is wrong in a way
|
||||||
/// the server reports as "authentication failed".
|
/// the server reports as "authentication failed".
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private sealed record HostAuthentication(string Username, SshCredential Credential);
|
private sealed record HostAuthentication(string Username, SshCredential Credential, string? IdentityLabel = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The machine a connection is being made to, however it was named.
|
/// The machine a connection is being made to, however it was named.
|
||||||
@@ -11359,6 +11389,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
HostSecret host,
|
HostSecret host,
|
||||||
string typedPassword,
|
string typedPassword,
|
||||||
[NotNullWhen(true)] out SshConnectionRequest? request,
|
[NotNullWhen(true)] out SshConnectionRequest? request,
|
||||||
|
out string? identityLabel,
|
||||||
[NotNullWhen(false)] out string? reason)
|
[NotNullWhen(false)] out string? reason)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(host);
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
@@ -11368,6 +11399,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
if (!TryBuildAuthentication(host, resolved, typedPassword, out var authentication, out reason))
|
if (!TryBuildAuthentication(host, resolved, typedPassword, out var authentication, out reason))
|
||||||
{
|
{
|
||||||
request = null;
|
request = null;
|
||||||
|
identityLabel = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11377,6 +11409,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
authentication.Username,
|
authentication.Username,
|
||||||
authentication.Credential);
|
authentication.Credential);
|
||||||
|
|
||||||
|
identityLabel = authentication.IdentityLabel;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11428,6 +11462,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
return Complete(
|
return Complete(
|
||||||
credential.Credential.Username ?? resolved.Username.Value,
|
credential.Credential.Username ?? resolved.Username.Value,
|
||||||
new SshPasswordCredential(credential.Credential.Password),
|
new SshPasswordCredential(credential.Credential.Password),
|
||||||
|
credential.Label,
|
||||||
out authentication,
|
out authentication,
|
||||||
out reason);
|
out reason);
|
||||||
}
|
}
|
||||||
@@ -11446,13 +11481,18 @@ internal sealed partial class VaultViewModel(
|
|||||||
resolved.Username.Value,
|
resolved.Username.Value,
|
||||||
new SshPrivateKeyCredential(
|
new SshPrivateKeyCredential(
|
||||||
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase),
|
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase),
|
||||||
|
key.Label,
|
||||||
out authentication,
|
out authentication,
|
||||||
out reason);
|
out reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No item to name: a typed password has nothing filed in the keychain, and IdentityLabel stays null —
|
||||||
|
// the default Complete's own signature gives it — rather than the placeholder text a display-only
|
||||||
|
// fallback would tempt someone to invent.
|
||||||
return Complete(
|
return Complete(
|
||||||
resolved.Username.Value,
|
resolved.Username.Value,
|
||||||
new SshPasswordCredential(typedPassword),
|
new SshPasswordCredential(typedPassword),
|
||||||
|
identityLabel: null,
|
||||||
out authentication,
|
out authentication,
|
||||||
out reason);
|
out reason);
|
||||||
}
|
}
|
||||||
@@ -11476,6 +11516,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
private static bool Complete(
|
private static bool Complete(
|
||||||
string? username,
|
string? username,
|
||||||
SshCredential credential,
|
SshCredential credential,
|
||||||
|
string? identityLabel,
|
||||||
out HostAuthentication? authentication,
|
out HostAuthentication? authentication,
|
||||||
[NotNullWhen(false)] out string? reason)
|
[NotNullWhen(false)] out string? reason)
|
||||||
{
|
{
|
||||||
@@ -11487,7 +11528,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
out reason);
|
out reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
authentication = new HostAuthentication(username, credential);
|
authentication = new HostAuthentication(username, credential, identityLabel);
|
||||||
reason = null;
|
reason = null;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,6 +265,17 @@ public interface ISftpSession : IRemoteFileStore
|
|||||||
{
|
{
|
||||||
/// <summary>The host key that was accepted for this session.</summary>
|
/// <summary>The host key that was accepted for this session.</summary>
|
||||||
HostKeyPresentation HostKey { get; }
|
HostKeyPresentation HostKey { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The negotiated server-to-client encryption algorithm, e.g. <c>aes256-gcm@openssh.com</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The same fact <see cref="ISshConnection.Cipher"/> is, read the same way — off SSH.NET's
|
||||||
|
/// <c>ConnectionInfo.CurrentServerEncryption</c> once the handshake this session's own connect performed
|
||||||
|
/// has finished — and for the same reason: <c>SftpClient</c> derives from <c>BaseClient</c> exactly as
|
||||||
|
/// <c>SshClient</c> does, and rekeys are no more visible here than they are there. See that member's remark.
|
||||||
|
/// </remarks>
|
||||||
|
string Cipher { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -98,6 +98,25 @@ public interface ISshConnection : IAsyncDisposable
|
|||||||
/// <summary>The host key that was accepted for this connection.</summary>
|
/// <summary>The host key that was accepted for this connection.</summary>
|
||||||
HostKeyPresentation HostKey { get; }
|
HostKeyPresentation HostKey { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The negotiated server-to-client encryption algorithm, e.g. <c>aes256-gcm@openssh.com</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Read once, immediately after the handshake, off SSH.NET's own <c>ConnectionInfo.CurrentServerEncryption</c>.
|
||||||
|
/// The only event that could make this stale is a rekey, and SSH.NET raises no event for one and exposes no
|
||||||
|
/// way to ask again — there is nothing behind this property to go and re-read. A captured value is therefore
|
||||||
|
/// not a snapshot that might drift; it is the only value there has ever been a moment to observe.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Server-to-client, not client-to-server.</b> SSH negotiates the two directions independently and a
|
||||||
|
/// server is free to choose differently for each, so the two can in principle disagree. This is the
|
||||||
|
/// direction the bytes drawn on a terminal pane travelled in, which is the fact a status bar showing what
|
||||||
|
/// the screen is made of should be naming.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
string Cipher { get; }
|
||||||
|
|
||||||
/// <summary>Opens an interactive shell with a pseudo-terminal.</summary>
|
/// <summary>Opens an interactive shell with a pseudo-terminal.</summary>
|
||||||
Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken);
|
Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,6 +274,16 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public HostKeyPresentation HostKey { get; } = hostKey;
|
public HostKeyPresentation HostKey { get; } = hostKey;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Read at construction rather than lazily: by the time an <see cref="SshNetConnection"/> exists,
|
||||||
|
/// <see cref="SshNetConnectionFactory.ConnectAsync"/> has already awaited <c>client.ConnectAsync</c>, so
|
||||||
|
/// <c>ConnectionInfo</c> is already populated and there is no earlier moment reading it would race. SSH.NET
|
||||||
|
/// types the property as a non-nullable <c>string</c>, so this reads straight through rather than coalescing
|
||||||
|
/// a null that the library's own contract says cannot occur.
|
||||||
|
/// </remarks>
|
||||||
|
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation h
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public HostKeyPresentation HostKey { get; } = hostKey;
|
public HostKeyPresentation HostKey { get; } = hostKey;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Read at construction, the same way and for the same reason as <c>SshNetConnection.Cipher</c>: this type
|
||||||
|
/// is only ever built after <c>SshNetConnectionFactory.OpenSftpAsync</c> has awaited <c>client.ConnectAsync</c>,
|
||||||
|
/// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from.
|
||||||
|
/// </remarks>
|
||||||
|
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string HomeDirectory { get; } = homeDirectory;
|
public string HomeDirectory { get; } = homeDirectory;
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs
|
|||||||
public uint SessionId { get; } = sessionId;
|
public uint SessionId { get; } = sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The negotiated cipher and host-key algorithm for one live session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Cipher">The server-to-client encryption algorithm; see <see cref="ISshConnection.Cipher"/>.</param>
|
||||||
|
/// <param name="HostKeyAlgorithm">The host key's algorithm, e.g. <c>ssh-ed25519</c>.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// Two facts rather than the whole <see cref="ISshConnection"/>, because that is all a caller outside this
|
||||||
|
/// assembly has any business reading off a session it does not own — everything else on the connection
|
||||||
|
/// (disposal, the shell) belongs to the workspace alone. See <see cref="TerminalWorkspace.GetSessionFacts"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record SessionFacts(string Cipher, string HostKeyAlgorithm);
|
||||||
|
|
||||||
/// <summary>The renderer asking for a different font size.</summary>
|
/// <summary>The renderer asking for a different font size.</summary>
|
||||||
/// <param name="step">
|
/// <param name="step">
|
||||||
/// How far to move, in points of font size, or zero to go back to the default. It is a step rather than a
|
/// How far to move, in points of font size, or zero to go back to the default. It is a step rather than a
|
||||||
@@ -180,6 +192,37 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The negotiated cipher and host-key algorithm for one live session, or null when the id names no
|
||||||
|
/// session this workspace still has open.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Additive, and deliberately narrow: the caller this exists for — the shell's own connect path, which
|
||||||
|
/// wants these two facts for its status bar — has no other business with a session it does not own, and a
|
||||||
|
/// method that handed back the <see cref="ISshConnection"/> itself would have handed over the shell,
|
||||||
|
/// disposal and all, to code that already goes through <see cref="CloseSessionAsync"/> for that.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// "No session this workspace still has open" covers two different absences the same way
|
||||||
|
/// <see cref="IsSessionLive"/> already does: an id this workspace never issued, and one whose shell has
|
||||||
|
/// already ended but whose entry has not been removed yet. Both are "nothing to report" to a caller
|
||||||
|
/// asking what a session's transport looks like right now.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public SessionFacts? GetSessionFacts(uint sessionId)
|
||||||
|
{
|
||||||
|
lock (sessionGate)
|
||||||
|
{
|
||||||
|
if (!sessions.TryGetValue(sessionId, out var session) || session.Run.IsCompleted)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SessionFacts(session.Connection.Cipher, session.Connection.HostKey.Algorithm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised with the session id when a shell ends on its own.
|
/// Raised with the session id when a shell ends on its own.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
|||||||
public HostKeyPresentation HostKey { get; } =
|
public HostKeyPresentation HostKey { get; } =
|
||||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string HomeDirectory => $"/home/{request.Username}";
|
public string HomeDirectory => $"/home/{request.Username}";
|
||||||
|
|
||||||
@@ -139,6 +142,9 @@ internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConn
|
|||||||
public HostKeyPresentation HostKey { get; } =
|
public HostKeyPresentation HostKey { get; } =
|
||||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ISshShellSession> OpenShellAsync(
|
public Task<ISshShellSession> OpenShellAsync(
|
||||||
TerminalSize size,
|
TerminalSize size,
|
||||||
|
|||||||
@@ -6221,7 +6221,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The status bar's facts, read off the selected terminal tab. <see cref="MainWindowViewModel.SessionElapsedText"/>
|
/// The status bar's facts, read off the selected terminal tab. <see cref="MainWindowViewModel.SessionElapsedText"/>
|
||||||
/// is real, not fabricated: <c>StartedAt</c> is set from the shell's own clock at the moment the session
|
/// is real, not fabricated: <c>StartedAt</c> is set from the shell's own clock at the moment the session
|
||||||
/// opens, and this reads it back through the same clock.
|
/// opens, and this reads it back through the same clock. The cipher and host-key algorithm are the
|
||||||
|
/// fake's own, and the identity text is the bound key's label — <c>ConnectedHostWithAPinAsync</c> binds
|
||||||
|
/// "deploy" — composed with the real (unshortened) algorithm string.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SessionFacts_ReflectTheSelectedTerminalTab()
|
public async Task SessionFacts_ReflectTheSelectedTerminalTab()
|
||||||
@@ -6231,6 +6233,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.IsSessionConnected.ShouldBeTrue();
|
shell.IsSessionConnected.ShouldBeTrue();
|
||||||
shell.SessionAddress.ShouldBe(shell.Tabs[0].Address);
|
shell.SessionAddress.ShouldBe(shell.Tabs[0].Address);
|
||||||
shell.SessionElapsedText.ShouldNotBeNull().ShouldStartWith("session ");
|
shell.SessionElapsedText.ShouldNotBeNull().ShouldStartWith("session ");
|
||||||
|
shell.SessionCipher.ShouldBe("aes256-gcm@openssh.com");
|
||||||
|
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
|
||||||
|
shell.SessionIdentityText.ShouldBe("ssh-ed25519 · deploy");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -6245,6 +6250,55 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.IsSessionConnected.ShouldBeFalse();
|
shell.IsSessionConnected.ShouldBeFalse();
|
||||||
shell.SessionAddress.ShouldBeNull();
|
shell.SessionAddress.ShouldBeNull();
|
||||||
shell.SessionElapsedText.ShouldBeNull();
|
shell.SessionElapsedText.ShouldBeNull();
|
||||||
|
shell.SessionCipher.ShouldBeNull();
|
||||||
|
shell.SessionHostKeyAlgorithm.ShouldBeNull();
|
||||||
|
shell.SessionIdentityText.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A typed password has nothing filed in the keychain to name, so the status bar's identity run is the
|
||||||
|
/// host-key algorithm alone — no " · " and nothing after it, which is what
|
||||||
|
/// <see cref="MainWindowViewModel.SessionIdentityText"/>'s own remark promises rather than a placeholder
|
||||||
|
/// standing in for the item that was never there.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
vault.ManualTarget = "deploy@build.internal:2222";
|
||||||
|
vault.ManualPassword = "hunter2";
|
||||||
|
|
||||||
|
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.IsSessionConnected.ShouldBeTrue();
|
||||||
|
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
|
||||||
|
shell.SessionIdentityText.ShouldBe("ssh-ed25519", "a typed password names no keychain item");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The same facts on the other surface, and one asymmetry worth pinning: the SFTP session is its own
|
||||||
|
/// login through the same resolution ladder, so the status bar names that session's cipher, host key and
|
||||||
|
/// identity — and stops naming them on disconnect, where a terminal tab keeps its facts for the
|
||||||
|
/// scrollback still on screen (see <c>TerminalTabViewModel.Cipher</c>'s remark).
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SessionFacts_FollowTheSftpSurfacesOwnSession()
|
||||||
|
{
|
||||||
|
await ConnectedHostWithAPinAsync();
|
||||||
|
|
||||||
|
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
|
||||||
|
|
||||||
|
shell.IsTransfersShowing.ShouldBeTrue();
|
||||||
|
shell.SessionCipher.ShouldBe("aes256-gcm@openssh.com");
|
||||||
|
shell.SessionIdentityText.ShouldBe("ssh-ed25519 · deploy");
|
||||||
|
|
||||||
|
await shell.Transfers.DisconnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.SessionCipher.ShouldBeNull("the SFTP surface has no scrollback for a dead session's facts to describe");
|
||||||
|
shell.SessionIdentityText.ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
|||||||
|
|
||||||
connection.IsConnected.ShouldBeTrue();
|
connection.IsConnected.ShouldBeTrue();
|
||||||
|
|
||||||
|
// The one place this suite checks Cipher against a real handshake rather than a fake's fixed string.
|
||||||
|
// SSH.NET negotiates whatever the container's sshd offers first from its own preference list, so the
|
||||||
|
// exact algorithm is not pinned here — only that ConnectionInfo.CurrentServerEncryption came back as
|
||||||
|
// something rather than the empty string a stalled or pre-handshake read would produce.
|
||||||
|
connection.Cipher.ShouldNotBeNullOrEmpty();
|
||||||
|
|
||||||
// Authenticated is not the same as usable: a channel has to open on the connection too.
|
// Authenticated is not the same as usable: a channel has to open on the connection too.
|
||||||
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
|
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer
|
|||||||
public HostKeyPresentation HostKey { get; } =
|
public HostKeyPresentation HostKey { get; } =
|
||||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
||||||
|
|
||||||
/// <summary>The shell this connection opened, if it opened one.</summary>
|
/// <summary>The shell this connection opened, if it opened one.</summary>
|
||||||
internal FakeShellSession? Shell { get; private set; }
|
internal FakeShellSession? Shell { get; private set; }
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,31 @@ public sealed class TerminalWorkspaceTests
|
|||||||
workspace.IsSessionLive(second).ShouldBeTrue("closing one tab must not disturb another");
|
workspace.IsSessionLive(second).ShouldBeTrue("closing one tab must not disturb another");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The shell's connect path reads these back once, right after <see cref="TerminalWorkspace.OpenSessionAsync"/>
|
||||||
|
/// returns, to fill in the status bar's cipher and host-key facts — see <c>VaultViewModel.ConnectAndAnnounceAsync</c>.
|
||||||
|
/// Asserted the same way <see cref="LivenessIsAnsweredPerSession"/> asserts liveness: per session, and
|
||||||
|
/// null rather than thrown for an id this workspace never issued.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SessionFactsAreReadPerSession()
|
||||||
|
{
|
||||||
|
var connections = new FakeConnectionFactory();
|
||||||
|
|
||||||
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var facts = workspace.GetSessionFacts(sessionId).ShouldNotBeNull();
|
||||||
|
var connection = connections.Connections.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
facts.Cipher.ShouldBe(connection.Cipher);
|
||||||
|
facts.HostKeyAlgorithm.ShouldBe(connection.HostKey.Algorithm);
|
||||||
|
|
||||||
|
workspace.GetSessionFacts(9999).ShouldBeNull("this workspace never issued that id");
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Inserting a snippet has to be able to say whether it arrived, and the transport cannot: it drops
|
/// Inserting a snippet has to be able to say whether it arrived, and the transport cannot: it drops
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ internal sealed class FakeSftpSession : ISftpSession
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");
|
public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string HomeDirectory => "/home/dodo";
|
public string HomeDirectory => "/home/dodo";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user