Skip to content

Commit 58dda83

Browse files
committed
Updates
1 parent ca478dd commit 58dda83

8 files changed

Lines changed: 197 additions & 8 deletions

File tree

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ JWT, OpenAPI, HPACK, QPACK — is built on what is in the box.
2323
| [Shiny.Net.HttpServer.DocumentDb](https://www.nuget.org/packages/Shiny.Net.HttpServer.DocumentDb) | Publishes a Shiny.DocumentDb type as a REST resource — list, by-id, count, CRUD, merge-patch and a live SSE tail |
2424
| [Shiny.Net.HttpServer.WebDav](https://www.nuget.org/packages/Shiny.Net.HttpServer.WebDav) | A WebDAV (RFC 4918) class 1 & 2 server over a directory — mount an app's storage in Finder, Windows Explorer or any WebDAV client |
2525
| [Shiny.Net.HttpServer.Grpc](https://www.nuget.org/packages/Shiny.Net.HttpServer.Grpc) | gRPC and gRPC-Web — unary, streaming and bidirectional methods over the same HTTP/2 stack, with serialization you supply |
26-
| [Shiny.Net.HttpServer.CommandLine](https://www.nuget.org/packages/Shiny.Net.HttpServer.CommandLine) | A .NET tool — `shinyhttpserver` — that serves a directory over HTTP with the file browser, with basic auth, per-operation permissions, and a QR code of the LAN address in the banner so a phone can scan its way in |
26+
| [Shiny.Net.HttpServer.CommandLine](https://www.nuget.org/packages/Shiny.Net.HttpServer.CommandLine) | A .NET tool — `shinyhttpserver` — that serves a directory over HTTP with the file browser, with basic auth, per-operation permissions, and a QR code in the banner so a phone can scan its way in`--tunnel` swaps the LAN address for a public pinggy.io tunnel so the phone need not be on the same network |
2727

2828
## Getting Started
2929

skills/shiny-httpserver/SKILL.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ triggers:
9999
- serve a directory from the command line
100100
- QR code in the terminal
101101
- open a served directory on a phone
102+
- share a folder over the internet
103+
- --tunnel
104+
- --tunnel-token
102105
- Shiny.Net.HttpServer.WebDav
103106
- MapWebDav
104107
- WebDavOptions
@@ -210,6 +213,14 @@ address plus the URL in full, so "get this folder onto my phone" is the tool ans
210213
`-a localhost` keeps it to the machine, `--no-qr` drops the code. Basic auth (`-u`) over plain HTTP
211214
refuses to start on a non-loopback address, which the default now is: pair it with `--https`.
212215

216+
`--tunnel` is the answer to "share this folder with someone not on my network": it opens a
217+
`QuickTunnel` to pinggy.io and the QR code carries the public HTTPS address instead of the LAN one.
218+
Because the tunnel feeds `HttpServer.ServeAsync` directly, `--tunnel -a localhost` binds nothing on
219+
the LAN and is reachable only through the tunnel — and a tunnelled connection counts as encrypted
220+
transport, so `-u` works over it without `--https` or `--allow-insecure-auth`. Anonymous tunnels stop
221+
after 60 minutes; `--tunnel-token <token>` lifts that and implies `--tunnel`. Always say that the
222+
address is public when you suggest it.
223+
213224
## The four tiers — the spine of this library
214225

215226
Every new API belongs to one of these. Say which when you introduce one. They compose in one app.

src/Shiny.Net.HttpServer.CommandLine/Cli.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,17 @@ public static RootCommand Build(Func<ServeSettings, CancellationToken, Task<int>
8383
Description = "Serves over HTTPS with a self-signed certificate generated at startup. Clients will warn about it."
8484
};
8585

86+
var tunnelOpt = new Option<bool>("--tunnel")
87+
{
88+
Description = "Opens a public pinggy.io tunnel and shares that address instead of the LAN one. Anonymous tunnels stop after 60 minutes."
89+
};
90+
91+
var tunnelTokenOpt = new Option<string?>("--tunnel-token")
92+
{
93+
Description = "A pinggy.io access token, which lifts the 60 minute cap an anonymous tunnel has. Implies --tunnel.",
94+
HelpName = "token"
95+
};
96+
8697
var hiddenOpt = new Option<bool>("--hidden")
8798
{
8899
Description = "Includes dotfiles and hidden files in listings and downloads."
@@ -118,6 +129,8 @@ public static RootCommand Build(Func<ServeSettings, CancellationToken, Task<int>
118129
authChangesOpt,
119130
insecureAuthOpt,
120131
httpsOpt,
132+
tunnelOpt,
133+
tunnelTokenOpt,
121134
hiddenOpt,
122135
maxUploadOpt,
123136
noQrOpt,
@@ -138,6 +151,8 @@ public static RootCommand Build(Func<ServeSettings, CancellationToken, Task<int>
138151
AuthChangesOnly = parseResult.GetValue(authChangesOpt),
139152
AllowInsecureAuth = parseResult.GetValue(insecureAuthOpt),
140153
UseHttps = parseResult.GetValue(httpsOpt),
154+
UseTunnel = parseResult.GetValue(tunnelOpt) || parseResult.GetValue(tunnelTokenOpt) is { Length: > 0 },
155+
TunnelToken = parseResult.GetValue(tunnelTokenOpt),
141156
ServeHidden = parseResult.GetValue(hiddenOpt),
142157
MaxUploadBytes = parseResult.GetRequiredValue(maxUploadOpt),
143158
ShowQr = !parseResult.GetValue(noQrOpt),

src/Shiny.Net.HttpServer.CommandLine/Runner.cs

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.ComponentModel;
12
using System.Net;
23
using System.Net.NetworkInformation;
34
using System.Net.Sockets;
@@ -6,6 +7,7 @@
67
using Microsoft.Extensions.Logging;
78
using Shiny.Net.HttpServer.FileBrowser;
89
using Shiny.Net.HttpServer.Security;
10+
using Shiny.Net.HttpServer.Ssh;
911

1012
namespace Shiny.Net.HttpServer.CommandLine;
1113

@@ -29,6 +31,11 @@ public static async Task<int> RunAsync(ServeSettings settings, CancellationToken
2931
o.TimestampFormat = "HH:mm:ss ";
3032
})
3133
.SetMinimumLevel(settings.Verbose ? LogLevel.Debug : LogLevel.Warning)
34+
35+
// The tunnel warns that it is trusting an unverified host key, which is exactly what a
36+
// quick tunnel does by design - and the banner says so in plainer words a few lines
37+
// later. Left in for --verbose, kept out of a normal run's first three lines.
38+
.AddFilter("Shiny.Net.HttpServer.Ssh", settings.Verbose ? LogLevel.Debug : LogLevel.Error)
3239
);
3340

3441
builder.Configure(o =>
@@ -103,7 +110,26 @@ public static async Task<int> RunAsync(ServeSettings settings, CancellationToken
103110
});
104111
}
105112

106-
PrintBanner(settings, prefix);
113+
// The tunnel hands connections straight to ServeAsync, so it is up before - and independent
114+
// of - the listener. That is what makes "--tunnel -a localhost" a real combination: nothing
115+
// on the LAN, everything through the tunnel.
116+
await using var tunnel = settings.UseTunnel
117+
? QuickTunnel.For(
118+
server,
119+
QuickTunnelHost.Pinggy,
120+
settings.TunnelToken,
121+
loggerFactory: server.Services?.GetService<ILoggerFactory>()
122+
)
123+
: null;
124+
125+
var tunnelUrl = tunnel is null ? null : await OpenTunnelAsync(tunnel, cancellationToken).ConfigureAwait(false);
126+
127+
PrintBanner(settings, prefix, tunnelUrl);
128+
129+
// The address changes on every reconnect, which kills whatever is already on screen - so a
130+
// new one is announced, with its own code, rather than leaving a dead link as the last word.
131+
if (tunnel is not null)
132+
tunnel.PropertyChanged += (_, e) => OnTunnelUrlChanged(settings, prefix, tunnel, e);
107133

108134
try
109135
{
@@ -124,6 +150,61 @@ public static async Task<int> RunAsync(ServeSettings settings, CancellationToken
124150
}
125151

126152

153+
/// <summary>
154+
/// Brings the tunnel up, or explains why there is none. A tunnel that will not open is not a
155+
/// reason to refuse to serve: the directory is still on this network, and the banner still has
156+
/// somewhere to point.
157+
/// </summary>
158+
static async Task<string?> OpenTunnelAsync(QuickTunnel tunnel, CancellationToken cancellationToken)
159+
{
160+
Console.WriteLine();
161+
Console.WriteLine(" opening tunnel...");
162+
163+
try
164+
{
165+
var url = await tunnel.StartAsync(cancellationToken).ConfigureAwait(false);
166+
if (url is { Length: > 0 })
167+
return url;
168+
169+
Error(tunnel.LastError ?? "The tunnel connected but never reported an address.");
170+
}
171+
catch (OperationCanceledException)
172+
{
173+
throw;
174+
}
175+
catch (Exception ex)
176+
{
177+
Error($"The tunnel could not be opened - {ex.Message}");
178+
}
179+
180+
Warn("Serving on this network only.");
181+
return null;
182+
}
183+
184+
185+
static void OnTunnelUrlChanged(ServeSettings settings, string prefix, QuickTunnel tunnel, PropertyChangedEventArgs e)
186+
{
187+
if (e.PropertyName != nameof(QuickTunnel.PublicUrl) || tunnel.PublicUrl is not { Length: > 0 } url)
188+
return;
189+
190+
Console.WriteLine();
191+
Warn("The tunnel reconnected on a new address. The previous one no longer answers.");
192+
Line("Tunnel", TunnelUrl(url, prefix));
193+
194+
if (settings.ShowQr)
195+
{
196+
Console.WriteLine();
197+
PrintQr(TunnelUrl(url, prefix));
198+
}
199+
Console.WriteLine();
200+
}
201+
202+
203+
/// <summary>The tunnel terminates at the site root, so the mount point has to be put back on.</summary>
204+
internal static string TunnelUrl(string url, string prefix)
205+
=> prefix == "/" ? url.TrimEnd('/') + "/" : url.TrimEnd('/') + prefix;
206+
207+
127208
static bool NeedsWriteGuard(Permissions permissions)
128209
{
129210
var create = permissions.Has(Permissions.Create);
@@ -159,6 +240,7 @@ Basic auth over plain HTTP on {settings.Address} would send the password across
159240
160241
Pick one:
161242
--https serve over TLS with a self-signed certificate
243+
--tunnel -a localhost reach it only through the tunnel, which is encrypted
162244
--address localhost keep the server on this machine
163245
--allow-insecure-auth send it anyway
164246
"""
@@ -180,7 +262,7 @@ static async ValueTask LogRequestAsync(HttpContext context, RequestDelegate next
180262
}
181263

182264

183-
static void PrintBanner(ServeSettings settings, string prefix)
265+
static void PrintBanner(ServeSettings settings, string prefix, string? tunnelUrl)
184266
{
185267
Console.WriteLine();
186268
Console.WriteLine("shinyhttpserver");
@@ -190,6 +272,9 @@ static void PrintBanner(ServeSettings settings, string prefix)
190272
foreach (var url in Urls(settings, prefix))
191273
Line("URL", url);
192274

275+
if (tunnelUrl is not null)
276+
Line("Tunnel", TunnelUrl(tunnelUrl, prefix));
277+
193278
Line("Operations", settings.Permissions.Describe());
194279
Line(
195280
"Auth",
@@ -203,14 +288,32 @@ static void PrintBanner(ServeSettings settings, string prefix)
203288

204289
Console.WriteLine();
205290

291+
// Said before the write warning, because it is what turns that warning from "the office
292+
// network" into "the internet".
293+
if (tunnelUrl is not null)
294+
{
295+
Warn(
296+
"The tunnel is public: anyone holding the address can reach this directory, and the traffic passes through pinggy.io."
297+
+ (settings.TunnelToken is { Length: > 0 } ? "" : " An anonymous tunnel stops after 60 minutes.")
298+
);
299+
}
300+
206301
if (settings.Permissions.AllowsChanges() && !settings.AuthEnabled)
207-
Warn("Writes are open to anyone who can reach this server. Add --user name:password.");
302+
{
303+
Warn(
304+
tunnelUrl is null
305+
? "Writes are open to anyone who can reach this server. Add --user name:password."
306+
: "Writes are open to anyone on the internet holding the tunnel address. Add --user name:password."
307+
);
308+
}
208309

209310
if (settings.UseHttps)
210311
Warn("The certificate is self-signed and generated at startup, so clients will not trust it.");
211312

313+
// The tunnel address is the one worth scanning when there is one: it reaches a phone that
314+
// is not on this network at all, which the LAN address does not.
212315
if (settings.ShowQr)
213-
PrintQr(settings, prefix);
316+
PrintQr(tunnelUrl is null ? ShareableUrl(settings, prefix) : TunnelUrl(tunnelUrl, prefix));
214317

215318
Console.WriteLine("Ctrl+C to stop");
216319
Console.WriteLine();
@@ -219,11 +322,10 @@ static void PrintBanner(ServeSettings settings, string prefix)
219322

220323
/// <summary>
221324
/// The point of the code is a phone that is not this machine, so it carries the address another
222-
/// device can reach - and nothing at all when the server is only listening to itself.
325+
/// device can reach - and nothing at all when there is no such address.
223326
/// </summary>
224-
static void PrintQr(ServeSettings settings, string prefix)
327+
static void PrintQr(string? url)
225328
{
226-
var url = ShareableUrl(settings, prefix);
227329
if (url == null || !QrCode.TryEncode(url, out var code))
228330
return;
229331

src/Shiny.Net.HttpServer.CommandLine/ServeSettings.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ public sealed record ServeSettings
2626

2727
public required bool UseHttps { get; init; }
2828

29+
/// <summary>Opens a pinggy.io quick tunnel, so the directory is reachable from off this network.</summary>
30+
public required bool UseTunnel { get; init; }
31+
32+
/// <summary>A pinggy.io access token, which lifts the 60 minute cap an anonymous tunnel has.</summary>
33+
public string? TunnelToken { get; init; }
34+
2935
/// <summary>Prints a scannable QR code of the address another device can reach.</summary>
3036
public bool ShowQr { get; init; } = true;
3137

src/Shiny.Net.HttpServer.CommandLine/Shiny.Net.HttpServer.CommandLine.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
<ItemGroup>
2424
<ProjectReference Include="../Shiny.Net.HttpServer/Shiny.Net.HttpServer.csproj" />
25+
<ProjectReference Include="../Shiny.Net.HttpServer.Ssh/Shiny.Net.HttpServer.Ssh.csproj" />
2526
</ItemGroup>
2627

2728
<ItemGroup>

tests/Shiny.Net.HttpServer.Tests/CommandLineTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,45 @@ public async Task Normalizes_a_prefix()
105105
Assert.Equal("/", (await ParseAsync("--prefix", "/")).UrlPrefix);
106106
}
107107

108+
/// <summary>
109+
/// The tunnel is what puts the directory on the public internet, so nothing but asking for it
110+
/// turns it on.
111+
/// </summary>
112+
[Fact]
113+
public async Task Leaves_the_tunnel_closed_unless_asked()
114+
{
115+
var settings = await ParseAsync();
116+
117+
Assert.False(settings.UseTunnel);
118+
Assert.Null(settings.TunnelToken);
119+
}
120+
121+
[Fact]
122+
public async Task Opens_a_tunnel_when_asked()
123+
=> Assert.True((await ParseAsync("--tunnel")).UseTunnel);
124+
125+
/// <summary>A token is only ever used to open a tunnel, so supplying one is asking for one.</summary>
126+
[Fact]
127+
public async Task Reads_a_tunnel_token_and_takes_it_as_asking_for_a_tunnel()
128+
{
129+
var settings = await ParseAsync("--tunnel-token", "abc123");
130+
131+
Assert.True(settings.UseTunnel);
132+
Assert.Equal("abc123", settings.TunnelToken);
133+
}
134+
135+
/// <summary>
136+
/// The tunnel terminates at the site root, so a prefixed mount has to be put back on - the
137+
/// address in the banner and in the QR code is the one that has to open the listing.
138+
/// </summary>
139+
[Theory]
140+
[InlineData("https://x.free.pinggy.net", "/", "https://x.free.pinggy.net/")]
141+
[InlineData("https://x.free.pinggy.net/", "/", "https://x.free.pinggy.net/")]
142+
[InlineData("https://x.free.pinggy.net", "/files", "https://x.free.pinggy.net/files")]
143+
[InlineData("https://x.free.pinggy.net/", "/files", "https://x.free.pinggy.net/files")]
144+
public void Puts_the_prefix_back_on_the_tunnel_address(string url, string prefix, string expected)
145+
=> Assert.Equal(expected, Runner.TunnelUrl(url, prefix));
146+
108147
[Theory]
109148
[InlineData(new[] { "--allow", "frobnicate" }, "is not an operation")]
110149
[InlineData(new[] { "--user", "nopassword" }, "is not a credential")]

tests/Shiny.Net.HttpServer.Tests/QrCodeTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,21 @@ public void Fits_an_ordinary_terminal()
159159
=> Assert.True(QrConsole.Width(Code()) <= 40);
160160

161161

162+
/// <summary>
163+
/// A tunnel address is far longer than a LAN one - pinggy builds the hostname out of the
164+
/// client's address, so an IPv6 machine gets the worst case. It still has to encode, and still
165+
/// has to fit a terminal, or "--tunnel" prints no code at all.
166+
/// </summary>
167+
[Fact]
168+
public void Fits_a_tunnel_address_too()
169+
{
170+
const string Url = "https://gyjjf-2605-8d80-5c0-d61d-616e-e6d7-7250-2508.free.pinggy.net/";
171+
172+
Assert.True(QrCode.TryEncode(Url, out var code));
173+
Assert.True(QrConsole.Width(code) <= 80);
174+
}
175+
176+
162177
[Fact]
163178
public void Draws_with_nothing_but_block_glyphs()
164179
=> Assert.All(QrConsole.Render(Code()), line => Assert.All(line.ToCharArray(), c => Assert.Contains(c, " █▀▄")));

0 commit comments

Comments
 (0)