Public Access
Merge pull request 'SFTP: stop waiting for ever on a host whose login prints text, and draw the file panes' icons whole' (#25) from claude/sftp-connection-icons-5ad752 into main
Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
commit
f7354aa7e8
5 files changed
+101
-11
No files matched your search
@@ -211,6 +211,31 @@ public sealed class SftpSessionTests(SshServerFixture fixture)
|
||||
refusal.Presentation.Fingerprint.ShouldStartWith("SHA256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AHostWhoseLoginPrintsText_FailsWithinTheConnectTimeout_AndSaysWhy()
|
||||
{
|
||||
var knownHosts = new InMemoryKnownHostStore();
|
||||
var factory = new SshNetConnectionFactory(knownHosts);
|
||||
|
||||
var request = Request() with { Port = fixture.ChattyPort, ConnectTimeout = TimeSpan.FromSeconds(3) };
|
||||
|
||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await factory.OpenSftpAsync(request, Token));
|
||||
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
||||
|
||||
// The banner's first four bytes read as the length of a packet about a gigabyte long, and SSH.NET's
|
||||
// own default is to wait for the rest of it for ever — which the file browser drew as "Connecting…"
|
||||
// with no end. The outer bound is only there so that regression fails this test rather than hangs it,
|
||||
// and it is a cancellation rather than a timeout so that it cannot be mistaken for the answer.
|
||||
using var bound = CancellationTokenSource.CreateLinkedTokenSource(Token);
|
||||
bound.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
|
||||
var failure = await Should.ThrowAsync<TimeoutException>(async () =>
|
||||
await factory.OpenSftpAsync(request, Token).WaitAsync(bound.Token));
|
||||
|
||||
failure.Message.ShouldContain("SFTP service did not answer within 3 seconds");
|
||||
}
|
||||
|
||||
private SshConnectionRequest Request() => new(
|
||||
fixture.Host,
|
||||
fixture.Port,
|
||||
|
||||
@@ -42,6 +42,17 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
|
||||
private const int SshPort = 2222;
|
||||
|
||||
/// <summary>
|
||||
/// A second port on the same sshd, where every session starts with a line of text before SFTP.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What a host with an <c>echo</c> in its login script looks like to a file-transfer client: the text
|
||||
/// arrives where the server's first SFTP packet should be. A port rather than an account because this
|
||||
/// image's sshd runs unprivileged and cannot log in as anybody but <see cref="Username"/>, and a port
|
||||
/// rather than a change to the main one so the shell and SFTP suites keep a server that behaves.
|
||||
/// </remarks>
|
||||
private const int ChattySshPort = 2223;
|
||||
|
||||
/// <summary>
|
||||
/// How many connections in a row the server has to answer before this fixture calls it ready.
|
||||
/// </summary>
|
||||
@@ -65,6 +76,9 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
/// <summary>Host port the container's sshd is published on.</summary>
|
||||
public ushort Port => container!.GetMappedPublicPort(SshPort);
|
||||
|
||||
/// <summary>Host port of the same sshd whose sessions print a banner first; see <see cref="ChattySshPort"/>.</summary>
|
||||
public ushort ChattyPort => container!.GetMappedPublicPort(ChattySshPort);
|
||||
|
||||
/// <summary>Host the container is reachable at.</summary>
|
||||
public string Host => container!.Hostname;
|
||||
|
||||
@@ -83,6 +97,7 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
.WithEnvironment("SUDO_ACCESS", "false")
|
||||
.WithEnvironment("PUBLIC_KEY", ExportOpenSshPublicKey(ClientKey))
|
||||
.WithPortBinding(SshPort, assignRandomHostPort: true)
|
||||
.WithPortBinding(ChattySshPort, assignRandomHostPort: true)
|
||||
|
||||
// The entrypoint generates host keys, rewrites sshd_config and installs the authorized
|
||||
// key before sshd is usable, so a published port is not readiness. Both conditions are
|
||||
@@ -176,6 +191,10 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
/// been touched — the same trap as the wrong file, one layer up. Measured from the log, after a version
|
||||
/// of this fixture did exactly that and failed twenty-eight tests.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="ChattySshPort"/> block goes last because a <c>Match</c> runs to the end of the file:
|
||||
/// anything appended after it would apply only to that port.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task ReconfigureAsync()
|
||||
{
|
||||
@@ -185,6 +204,10 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
"sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /config/sshd/sshd_config"
|
||||
+ " && sed -i 's/^#*MaxStartups .*/MaxStartups 200/' /config/sshd/sshd_config"
|
||||
+ " && printf '\\nPerSourcePenalties no\\n' >> /config/sshd/sshd_config"
|
||||
+ $" && sed -i 's/^Port {SshPort}$/Port {SshPort}\\nPort {ChattySshPort}/' /config/sshd/sshd_config"
|
||||
+ $" && printf '\\nMatch LocalPort {ChattySshPort}\\n"
|
||||
+ " ForceCommand /bin/sh -c \"echo Welcome to the test server; exec /usr/lib/ssh/sftp-server\"\\n'"
|
||||
+ " >> /config/sshd/sshd_config"
|
||||
+ " && pkill -HUP sshd",
|
||||
]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user