Public Access
A server Spelunk manages runs Spelunk's agent, which keeps a connection open
to Spelunk. A host can now be marked "Connect through Spelunk": a connect
asks Spelunk for a ticket, opens a WebSocket, and the agent connects to the
SSH server on the machine's own loopback, so the server needs no open SSH
port. Shells and file transfers both go this way.
Client, in Client.Session:
- SpelunkTunnel implements Spelunk's contract: POST
{api}/api/servers/{id}/tunnel-tickets with the API key as a bearer token
and {"port":N}, then a WebSocket to the API's own origin at the path
Spelunk returns, offering spelunk.tunnel.v1 and ticket.<ticket>. After the
upgrade the relay's RelayBridge carries the bytes unchanged. Every refusal
is a sentence: 401 key, 403 role (with Spelunk's detail), 404 server, 503
agent not connected, 401/503 on the upgrade, and close codes 4502 (agent
could not reach the port, or is too old: "Unknown stream kind") and 4503
(agent disconnected). Only a path is taken from the ticket response, so it
cannot send the ticket to another host.
- IConnectionRoute is what RelayRoute and SpelunkTunnelRoute have in common,
so the connect path has one shape for both.
Host model: HostSecret.Spelunk is a SpelunkRoute (normalised API base,
server id, and the id of the credential holding the API key), at payload
schema version 8. It is written last and only when present, so every other
host keeps its bytes and its version; an older build reads a Spelunk host
as read-only rather than dropping the route. A half-written route makes the
item unreadable instead of dialled. It merges as one value and is exclusive
with RelayEnabled. The API address must be https, or http to loopback.
The API key is an ordinary keychain credential whose password is the key,
referenced by id, so it is encrypted, synced, merged and rotated like every
other secret, one credential serves every server in the organisation, and
the used-by list and deletion warning count it.
Routing: OpenRouteAsync is the one decision for both transports. A Spelunk
host is never dialled directly, never probed after a failure, and a route
that hung up with a reason replaces SSH.NET's transport error.
UI: a "Connect through Spelunk" card beside the relay card in the desktop
drawer and the phone's editor; ticking one unticks the other.
dodossh://spelunk/connect?api=&server=&name=&host= links:
- SpelunkLink parses them strictly and never reads a credential from one.
- Program reads a link from the arguments; a launch that finds DodoSSH
running passes it down the doorbell as "dodossh.open-link <link>", which
the running instance parses again, bounded, and otherwise ignores whole.
The plain come-forward ring is unchanged.
- A saved host routed to that server on that Spelunk is connected to;
otherwise the editor opens on a new, unsaved Spelunk host, with the key
pre-chosen only when another host already sends it to that Spelunk. A link
that arrives while locked waits for the unlock.
- Windows registers the scheme under HKCU from Velopack's install and update
hooks and removes it on uninstall only if it still points at this
executable; the macOS plist template declares CFBundleURLTypes and the app
takes the URL from Avalonia's activation event; the Linux desktop entry
gets %u and MimeType=x-scheme-handler/dodossh.
492 lines
22 KiB
C#
492 lines
22 KiB
C#
using System.IO.Pipes;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using DodoSSH.Client.Session;
|
|
|
|
namespace DodoSSH.Client.App.Platform;
|
|
|
|
/// <summary>
|
|
/// One desktop instance per profile, and a way for a second launch to raise the first.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Per profile rather than per machine, which decides two things at once.</b> The lock lives in
|
|
/// <see cref="ClientPaths.InstanceLockFile"/>, so a nightly and a release build never exclude each other —
|
|
/// they are separate applications by design, ADR 0013 — and two people signed in to one machine each get
|
|
/// their own instance. A global mutex would have had to re-derive both of those from somewhere.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Why one instance at all.</b> The cache is a SQLite file with an outbox in it, and the whole design
|
|
/// assumes one writer: <see cref="ClientPaths"/> says so about two machines sharing a profile through a
|
|
/// cloud sync client, and two processes on one machine is the same hazard with a shorter path to it. The
|
|
/// terminal's loopback listener is the visible half — the second process fails to bind, gets a port of its
|
|
/// own or refuses to start a workspace at all — and the cache is the half that would quietly corrupt.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A file handle rather than a named mutex</b>, because the operating system releases it when the
|
|
/// process ends <em>however</em> it ends. A named mutex abandoned by a killed process is a state both
|
|
/// platforms model differently and neither models simply; an exclusive handle is closed by the kernel on
|
|
/// Windows and by the file descriptor going away on Unix, so there is no such thing as a stale lock here.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>And a named pipe for the other half, because exiting silently is not an answer.</b> Somebody who
|
|
/// double-clicks the icon and sees nothing happen has been told the application is broken. The second
|
|
/// process asks the first to come forward and then leaves, which is what every desktop application does and
|
|
/// what makes the exclusion invisible rather than mysterious.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A launch that carries a link passes the link on.</b> Clicking "Open in DodoSSH" in Spelunk starts
|
|
/// this executable with a <c>dodossh://</c> link, and while DodoSSH is already running that start is a
|
|
/// second launch like any other — so the doorbell has a second message, which carries the link to the
|
|
/// instance that can act on it. See <see cref="OpenLinkPrefix"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static class SingleInstance
|
|
{
|
|
/// <summary>The one thing a second launch has to say.</summary>
|
|
/// <remarks>
|
|
/// Compared against a constant rather than parsed, for the reason <c>MainWindow</c>'s release-focus
|
|
/// message is: one message exists and treating anything else as a command would be a wider door than
|
|
/// this needs. Anything may connect to a per-user pipe — it is not a security boundary — so what arrives
|
|
/// is checked rather than trusted.
|
|
/// </remarks>
|
|
private const string ComeForward = "dodossh.come-forward";
|
|
|
|
/// <summary>The other thing a second launch may say: come forward, and open this link.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Followed by the link, on the same one line. Checked as strictly as <see cref="ComeForward"/> is, and
|
|
/// for the same reason: anything on this machine may write to the pipe, so the link is parsed again here
|
|
/// by <see cref="SpelunkLink.TryParse"/>, and a message that is not exactly this prefix and a link that
|
|
/// passes is ignored whole — the window does not even come forward for it.
|
|
/// </para>
|
|
/// <para>
|
|
/// A second message rather than a second line after <see cref="ComeForward"/>, so that an older running
|
|
/// build, which compares the whole line against that constant, ignores a link it cannot act on rather
|
|
/// than half-obeying it.
|
|
/// </para>
|
|
/// </remarks>
|
|
private const string OpenLinkPrefix = "dodossh.open-link ";
|
|
|
|
/// <summary>The longest message the doorbell reads before it stops listening to that caller.</summary>
|
|
/// <remarks>
|
|
/// The longest link a second launch could send, and its prefix. A caller that goes on past it is not a
|
|
/// DodoSSH, and reading a line of whatever length it likes would let it decide how much memory this
|
|
/// process spends on it.
|
|
/// </remarks>
|
|
private const int MaxMessageLength = 64 + SpelunkLink.MaxLength;
|
|
|
|
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
|
|
/// <remarks>
|
|
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
|
|
/// not a bound on anything slow; it is how long to wait before concluding that whoever holds the lock
|
|
/// cannot be talked to.
|
|
/// </remarks>
|
|
private const int AnswerTimeoutMilliseconds = 500;
|
|
|
|
/// <summary>How long to keep trying for a lock that is held by nobody who answers.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>This is the window an update relaunch lands in, and without it an update would look like a
|
|
/// crash.</b> Velopack's apply ends this process and its updater then starts the new build; the two are
|
|
/// ordered — the updater waits on the old process id — but the whole sequence is one where a lock held a
|
|
/// moment too long would make the relaunched application exit on the spot, having apparently uninstalled
|
|
/// itself. The same window covers an ordinary double-double-click, where the first process holds the file
|
|
/// and has not reached its pipe yet.
|
|
/// </para>
|
|
/// <para>
|
|
/// Only reached when nobody answers the pipe. A live instance replies in milliseconds and the second
|
|
/// launch is gone before any of this matters.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static readonly TimeSpan DefaultPatience = TimeSpan.FromSeconds(3);
|
|
|
|
/// <summary>How long to wait between attempts within that window.</summary>
|
|
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(100);
|
|
|
|
/// <summary>
|
|
/// Claims this profile for this process, or asks whoever holds it to come forward.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// A handle to hold for the life of the process, or <see langword="null"/> when another instance has it —
|
|
/// in which case that instance has already been asked to raise its window and this process should end.
|
|
/// </returns>
|
|
/// <param name="paths">The profile to claim. Its directory is created if it is not there.</param>
|
|
/// <param name="patience">
|
|
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
|
|
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
|
|
/// </param>
|
|
/// <param name="link">
|
|
/// The link this launch was started with, if any — passed to the running instance instead of a bare
|
|
/// request to come forward.
|
|
/// </param>
|
|
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null, SpelunkLink? link = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(paths);
|
|
|
|
try
|
|
{
|
|
paths.EnsureCreated();
|
|
}
|
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
|
{
|
|
// No profile directory means no lock file, and every build before this one started anyway. What
|
|
// follows will fail on its own terms and say something better than this could.
|
|
return NoClaim.Instance;
|
|
}
|
|
|
|
// Counted rather than clocked. Thread.Sleep is not a clock and reading one here would be pretending
|
|
// otherwise — what this bounds is the number of attempts, and the wall time is whatever the platform
|
|
// makes of them.
|
|
var attemptsLeft = (int)Math.Ceiling(
|
|
(patience ?? DefaultPatience).TotalMilliseconds / RetryDelay.TotalMilliseconds);
|
|
|
|
while (true)
|
|
{
|
|
if (TryTake(paths.InstanceLockFile) is { } taken)
|
|
{
|
|
return taken;
|
|
}
|
|
|
|
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
|
|
// round trip on a local pipe rather than the whole window below.
|
|
if (AskTheRunningOneToComeForward(paths, link))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (attemptsLeft-- <= 0)
|
|
{
|
|
// Held by something that will not talk. Giving up rather than starting anyway, because what
|
|
// is being protected is a database with one writer, and "probably nobody" is not an answer
|
|
// worth corrupting it for.
|
|
return null;
|
|
}
|
|
|
|
Thread.Sleep(RetryDelay);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Answers the launches that were refused, for as long as the returned handle is held.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <paramref name="comeForward"/> is invoked on a thread pool thread, because that is where a pipe
|
|
/// accept completes. A caller that touches a window has to marshal.
|
|
/// </remarks>
|
|
/// <param name="paths">The profile this instance claimed.</param>
|
|
/// <param name="comeForward">What to do when another launch asks for the window.</param>
|
|
/// <param name="openLink">
|
|
/// What to do with a link another launch passed on, after <paramref name="comeForward"/>; null to ignore
|
|
/// links, which then still bring the window forward. Invoked on the same thread pool thread.
|
|
/// </param>
|
|
internal static IAsyncDisposable Listen(
|
|
ClientPaths paths,
|
|
Action comeForward,
|
|
Action<SpelunkLink>? openLink = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(paths);
|
|
ArgumentNullException.ThrowIfNull(comeForward);
|
|
|
|
var listener = new Doorbell(PipeNameFor(paths), comeForward, openLink);
|
|
|
|
listener.Start();
|
|
|
|
return listener;
|
|
}
|
|
|
|
/// <summary>Tells the instance that holds this profile to raise its window, and to open a link if given one.</summary>
|
|
/// <returns>Whether anything was listening.</returns>
|
|
/// <remarks>
|
|
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
|
|
/// and proves itself, and this is the part with a protocol in it.
|
|
/// </remarks>
|
|
internal static bool AskTheRunningOneToComeForward(ClientPaths paths, SpelunkLink? link = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(paths);
|
|
|
|
try
|
|
{
|
|
using var pipe = new NamedPipeClientStream(
|
|
serverName: ".",
|
|
PipeNameFor(paths),
|
|
PipeDirection.Out);
|
|
|
|
pipe.Connect(AnswerTimeoutMilliseconds);
|
|
|
|
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
|
|
|
|
// The link as this build writes it, rather than as it arrived: the receiver parses what a DodoSSH
|
|
// sent, and a link that parsed here has exactly one spelling. See SpelunkLink.ToString.
|
|
writer.WriteLine(Message(link));
|
|
|
|
return true;
|
|
}
|
|
catch (Exception exception)
|
|
when (exception is TimeoutException or IOException or UnauthorizedAccessException)
|
|
{
|
|
// Nothing there, or nothing willing. The caller decides what that means; here it is only ever
|
|
// "no".
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>What a launch says down the pipe: come forward, with or without a link to open.</summary>
|
|
internal static string Message(SpelunkLink? link) =>
|
|
link is null ? ComeForward : OpenLinkPrefix + link;
|
|
|
|
/// <summary>
|
|
/// Reads what a caller said: whether it is a ring at all, and the link that came with it.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// True for exactly <see cref="ComeForward"/>, with no link, and for <see cref="OpenLinkPrefix"/> followed
|
|
/// by a link <see cref="SpelunkLink.TryParse"/> accepts, with that link. False for everything else,
|
|
/// including a link that is almost right — the caller then does nothing at all.
|
|
/// </returns>
|
|
internal static bool TryRead(string? said, out SpelunkLink? link)
|
|
{
|
|
link = null;
|
|
|
|
if (said is null || said.Length > MaxMessageLength)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (said.StartsWith(OpenLinkPrefix, StringComparison.Ordinal)
|
|
&& SpelunkLink.TryParse(said[OpenLinkPrefix.Length..], out var parsed))
|
|
{
|
|
link = parsed;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
|
|
/// <remarks>
|
|
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
|
|
/// tidier and is worse: it makes the file's existence part of the protocol, so a second process opening
|
|
/// it while the first is closing gets a handle to something on its way out. An empty file that is always
|
|
/// there and sometimes locked has one fewer state.
|
|
/// </remarks>
|
|
private static IDisposable? TryTake(string lockFile)
|
|
{
|
|
try
|
|
{
|
|
return new FileStream(
|
|
lockFile,
|
|
FileMode.OpenOrCreate,
|
|
FileAccess.ReadWrite,
|
|
FileShare.None,
|
|
bufferSize: 1,
|
|
FileOptions.None);
|
|
}
|
|
catch (IOException)
|
|
{
|
|
// Somebody else holds it. The one expected failure, and the whole point.
|
|
return null;
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
// A profile this user cannot write to, which is a broken installation rather than a second
|
|
// instance. Answered as a claim so the application starts and fails on something legible.
|
|
return NoClaim.Instance;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The pipe both sides name, derived from the profile so that it is scoped exactly as the lock is.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A digest of the path rather than the path itself: a pipe name may not contain a directory separator
|
|
/// on Windows, and on Unix the name becomes a file in a temporary directory whose length is bounded.
|
|
/// Eight bytes of SHA-256, which is not doing security work — it is making one string out of another
|
|
/// deterministically, and <c>string.GetHashCode</c> cannot because it is randomised per process.
|
|
/// </remarks>
|
|
private static string PipeNameFor(ClientPaths paths)
|
|
{
|
|
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(paths.DataDirectory));
|
|
|
|
return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8));
|
|
}
|
|
|
|
/// <summary>A claim over nothing, for the cases where there was nothing to claim.</summary>
|
|
private sealed class NoClaim : IDisposable
|
|
{
|
|
internal static NoClaim Instance { get; } = new();
|
|
|
|
public void Dispose()
|
|
{
|
|
// Nothing was taken.
|
|
}
|
|
}
|
|
|
|
/// <summary>The accept loop that answers refused launches.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// One server instance for the life of the doorbell, disconnected between callers rather than disposed
|
|
/// and rebuilt. The loop is what makes a third and fourth launch work as well as the second.
|
|
/// </para>
|
|
/// <para>
|
|
/// ◆ <b>THE ONE INSTANCE IS THE WHOLE CORRECTNESS OF THIS CLASS ON LINUX, AND A FRESH ONE PER CALLER
|
|
/// LOSES LAUNCHES.</b> This was written the obvious way — a <c>using</c> inside the loop, a new stream
|
|
/// per connection — which is fine on Windows and quietly wrong on Unix, where a named pipe is a Unix
|
|
/// domain socket. Disposing the server there closes the <em>listening</em> socket and unlinks its path,
|
|
/// so a second launch that connected while the first was being served has its connection dropped on the
|
|
/// floor: its <c>Connect</c> returned, its write succeeded into a buffer, and nobody ever read it. The
|
|
/// launch was told it had rung the doorbell and the window never came forward.
|
|
/// </para>
|
|
/// <para>
|
|
/// Measured rather than reasoned about, in a container, because the two platforms disagree and this
|
|
/// machine is only one of them: three rings back to back are answered three times with one instance and
|
|
/// once with a fresh one per connection. <c>Disconnect</c> ends the conversation and keeps the socket, so
|
|
/// there is never a moment with nothing listening.
|
|
/// </para>
|
|
/// <para>
|
|
/// Which makes <c>leaveOpen</c> on the reader load-bearing as well. A <see cref="StreamReader"/> disposes
|
|
/// the stream it was given, and the stream is now the server that has to survive into the next
|
|
/// iteration — so the reader that was harmless per-connection would close the doorbell after one ring.
|
|
/// </para>
|
|
/// </remarks>
|
|
private sealed class Doorbell : IAsyncDisposable
|
|
{
|
|
private readonly string name;
|
|
private readonly Action comeForward;
|
|
private readonly Action<SpelunkLink>? openLink;
|
|
private readonly CancellationTokenSource lifetime = new();
|
|
|
|
private Task? loop;
|
|
|
|
internal Doorbell(string name, Action comeForward, Action<SpelunkLink>? openLink)
|
|
{
|
|
this.name = name;
|
|
this.comeForward = comeForward;
|
|
this.openLink = openLink;
|
|
}
|
|
|
|
internal void Start() => loop = RunAsync(lifetime.Token);
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await lifetime.CancelAsync().ConfigureAwait(false);
|
|
|
|
if (loop is { } running)
|
|
{
|
|
// Awaited rather than abandoned, so the pipe name is free by the time this returns — which
|
|
// matters for exactly one caller: an update that is about to relaunch this application and
|
|
// would otherwise race its own successor for the name.
|
|
await running.ConfigureAwait(false);
|
|
}
|
|
|
|
lifetime.Dispose();
|
|
}
|
|
|
|
private async Task RunAsync(CancellationToken cancellationToken)
|
|
{
|
|
using var server = new NamedPipeServerStream(
|
|
name,
|
|
PipeDirection.In,
|
|
maxNumberOfServerInstances: 1,
|
|
PipeTransmissionMode.Byte,
|
|
PipeOptions.Asynchronous);
|
|
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
// leaveOpen, because this server outlives the conversation. See the remark above.
|
|
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
|
|
{
|
|
var said = await ReadBoundedLineAsync(reader, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (TryRead(said, out var link))
|
|
{
|
|
comeForward();
|
|
|
|
if (link is not null)
|
|
{
|
|
openLink?.Invoke(link);
|
|
}
|
|
}
|
|
}
|
|
|
|
HangUp(server);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return;
|
|
}
|
|
catch (IOException)
|
|
{
|
|
// A client that connected and hung up mid-sentence. Round again: giving up here would
|
|
// leave every later launch with nothing to talk to, which is the silent-exit behaviour
|
|
// this class exists to avoid.
|
|
HangUp(server);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads one line, or null once it runs past <see cref="MaxMessageLength"/> or the caller hangs up.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="StreamReader.ReadLineAsync(CancellationToken)"/> reads a line of whatever length the
|
|
/// caller sends, and the caller is anybody on this machine. A character at a time is cheap here — the
|
|
/// reader buffers underneath, and a real message is one short line.
|
|
/// </remarks>
|
|
private static async Task<string?> ReadBoundedLineAsync(
|
|
StreamReader reader,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var line = new StringBuilder();
|
|
var next = new char[1];
|
|
|
|
while (await reader.ReadAsync(next.AsMemory(), cancellationToken).ConfigureAwait(false) == 1)
|
|
{
|
|
if (next[0] == '\n')
|
|
{
|
|
return line.ToString().TrimEnd('\r');
|
|
}
|
|
|
|
if (line.Length == MaxMessageLength)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
line.Append(next[0]);
|
|
}
|
|
|
|
return line.Length == 0 ? null : line.ToString().TrimEnd('\r');
|
|
}
|
|
|
|
/// <summary>Ends one conversation without ending the doorbell.</summary>
|
|
/// <remarks>
|
|
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
|
|
/// an <see cref="IOException"/> above may already have left it in, and the state a second call would
|
|
/// find. Throwing out of the error path would take the loop with it.
|
|
/// </remarks>
|
|
private static void HangUp(NamedPipeServerStream server)
|
|
{
|
|
try
|
|
{
|
|
if (server.IsConnected)
|
|
{
|
|
server.Disconnect();
|
|
}
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
// Already disconnected. Nothing to end.
|
|
}
|
|
}
|
|
}
|
|
}
|