mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
Localtunnel hosting + admin panel + AppData db
- Default hosting mode is now Localtunnel — public *.loca.lt URL, no account, auto-fetched on first run. Replaces the prototyped Tailscale mode. - Tailscale code purged from launcher + CLI; docs swapped to Localtunnel throughout (easy-setup, joining-a-server, server-cli, troubleshooting, faq, READMEs). - Launcher sidebar gains an Open admin panel button — opens https://admin.<apex> in the browser once the server is live. - SQLite db moves to %APPDATA%\DorkNet\dorknet.db (server reads Database__SqlitePath env var, launcher passes it). Existing dbs under <serverDir>\data\ are migrated on first run. - CLI Program.cs: --mode default flips from tailscale to localtunnel; csproj links in LocaltunnelTunnel + LocaltunnelInstaller. - New Backend files: LauncherNameServer, LocaltunnelInstaller, LocaltunnelTunnel, SingleOriginServiceMap. - .gitignore: exclude installer/out/.
This commit is contained in:
@@ -39,13 +39,14 @@ public sealed class AppState
|
||||
[JsonPropertyName("photonRegion")]
|
||||
public string PhotonRegion { get; set; } = "us";
|
||||
|
||||
/// <summary>How the host exposes the server: <c>Internet</c> uses
|
||||
/// Tunnelto (works for friends anywhere with the join code);
|
||||
/// <c>LocalNetwork</c> binds on the LAN and uses an sslip.io
|
||||
/// hostname in the join code (joiners must be on the same
|
||||
/// network).</summary>
|
||||
/// <summary>How the host exposes the server: <c>SingleOriginTunnel</c>
|
||||
/// (default) opens one public Localtunnel HTTPS URL plus a
|
||||
/// launcher-hosted local name server; <c>LocalNetwork</c> binds on
|
||||
/// the LAN and uses an sslip.io hostname in the join code (joiners
|
||||
/// must be on the same network); <c>RemoteWildcard</c> is the
|
||||
/// legacy provider-owned-apex mode kept for back-compat.</summary>
|
||||
[JsonPropertyName("hostingMode")]
|
||||
public HostingMode HostingMode { get; set; } = HostingMode.Internet;
|
||||
public HostingMode HostingMode { get; set; } = HostingMode.SingleOriginTunnel;
|
||||
|
||||
/// <summary>Provider-owned wildcard tunnel base for remote hosting
|
||||
/// without Cloudflare, e.g. <c>dorknet.tunnelto.me</c>. The server
|
||||
@@ -109,6 +110,11 @@ public sealed class AppState
|
||||
// the authoritative source instead of needing to re-derive
|
||||
// them every load.
|
||||
if (migrated) { try { state.Save(); } catch { } }
|
||||
if (state.HostingMode is HostingMode.RemoteWildcard or HostingMode.Internet)
|
||||
{
|
||||
state.HostingMode = HostingMode.SingleOriginTunnel;
|
||||
try { state.Save(); } catch { }
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -138,8 +144,14 @@ public enum AppMode
|
||||
|
||||
public enum HostingMode
|
||||
{
|
||||
/// <summary>Default — Tunnelto tunnel, public over the internet,
|
||||
/// works for joiners anywhere.</summary>
|
||||
/// <summary>Easy Launcher remote mode — one public HTTPS tunnel URL.
|
||||
/// The launcher answers the client's localhost name-server bootstrap
|
||||
/// and points every RecNet service at path prefixes under that URL.</summary>
|
||||
SingleOriginTunnel,
|
||||
/// <summary>Legacy "Friends anywhere" mode kept for migration
|
||||
/// purposes only. New installs are normalised to
|
||||
/// <see cref="SingleOriginTunnel"/> at <see cref="Load"/> time
|
||||
/// (see the migration block there).</summary>
|
||||
Internet,
|
||||
/// <summary>LAN only — bind on all local interfaces, skip the
|
||||
/// tunnel, and publish an sslip.io hostname for the machine's LAN
|
||||
|
||||
@@ -58,10 +58,11 @@ public sealed class ClientPatcher
|
||||
string photonVoiceAppId,
|
||||
string photonRegion,
|
||||
string apexHost,
|
||||
bool singleOrigin = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.Run(() => ApplyCore(patcherDir, recRoomDataPath,
|
||||
photonAppId, photonVoiceAppId, photonRegion, apexHost), ct);
|
||||
photonAppId, photonVoiceAppId, photonRegion, apexHost, singleOrigin), ct);
|
||||
}
|
||||
|
||||
private PatchResult ApplyCore(
|
||||
@@ -70,7 +71,8 @@ public sealed class ClientPatcher
|
||||
string photonAppId,
|
||||
string photonVoiceAppId,
|
||||
string photonRegion,
|
||||
string apexHost)
|
||||
string apexHost,
|
||||
bool singleOrigin)
|
||||
{
|
||||
var log = new System.Text.StringBuilder();
|
||||
try
|
||||
@@ -217,7 +219,8 @@ public sealed class ClientPatcher
|
||||
.Replace("{PHOTON_APPID}", photonAppId ?? "")
|
||||
.Replace("{PHOTON_VOICE_APPID}",
|
||||
string.IsNullOrEmpty(photonVoiceAppId) ? photonAppId ?? "" : photonVoiceAppId)
|
||||
.Replace("{PHOTON_REGION}", string.IsNullOrEmpty(photonRegion) ? "us" : photonRegion);
|
||||
.Replace("{PHOTON_REGION}", string.IsNullOrEmpty(photonRegion) ? "us" : photonRegion)
|
||||
.Replace("{SINGLE_ORIGIN_BASE_URL}", singleOrigin ? $"https://{apexHost}" : "");
|
||||
File.WriteAllText(configDest, rendered);
|
||||
log.AppendLine($"[patch] wrote config: {Path.GetFileName(configDest)}");
|
||||
}
|
||||
|
||||
@@ -61,7 +61,17 @@ public static class ErrorTranslator
|
||||
|
||||
private static FriendlyError? TryMatch(Exception ex)
|
||||
{
|
||||
// Network — GitHub releases, Tunnelto tunnel API, etc.
|
||||
if (ex.Message.Contains("localtunnel", StringComparison.OrdinalIgnoreCase) ||
|
||||
ex.Message.Contains("loca.lt", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new FriendlyError(
|
||||
"Localtunnel couldn't get a URL.",
|
||||
TrimToSentence(ex.Message) +
|
||||
" Localtunnel sometimes rate-limits or blips. Retry in a minute, or switch hosting mode to LAN if your friends are local.",
|
||||
"Retry");
|
||||
}
|
||||
|
||||
// Network — GitHub releases, legacy tunnel APIs, etc.
|
||||
if (ex is HttpRequestException http)
|
||||
{
|
||||
return new FriendlyError(
|
||||
@@ -80,6 +90,17 @@ public static class ErrorTranslator
|
||||
"Retry");
|
||||
}
|
||||
|
||||
if (ex is TimeoutException timeout)
|
||||
{
|
||||
var title = timeout.Message.Contains("Tunnelto", StringComparison.OrdinalIgnoreCase)
|
||||
? "Tunnelto tunnel timed out."
|
||||
: "Download timed out.";
|
||||
return new FriendlyError(
|
||||
title,
|
||||
TrimToSentence(timeout.Message) + " Try again in a minute; if it repeats, Tunnelto or GitHub may be blocked on this network.",
|
||||
"Retry");
|
||||
}
|
||||
|
||||
if (ex is SocketException sock)
|
||||
{
|
||||
return new FriendlyError(
|
||||
|
||||
@@ -6,8 +6,9 @@ using System.Text.Json.Serialization;
|
||||
namespace DorkNet.Launcher.Backend;
|
||||
|
||||
/// <summary>Compact URL-safe encoding of the bits a joining player
|
||||
/// needs: the host's public address (typically a Cloudflare-Tunnel
|
||||
/// hostname), the Photon AppIds + region they used, the host's
|
||||
/// needs: the host address (a Localtunnel <c>*.loca.lt</c> URL for the
|
||||
/// default mode, or an <c>sslip.io</c> name for LAN), the Photon
|
||||
/// AppIds + region they used, the host's
|
||||
/// server name, and the version key so the joiner's launcher fetches
|
||||
/// the matching client patcher.
|
||||
///
|
||||
@@ -44,9 +45,10 @@ public static class JoinCode
|
||||
|
||||
public sealed class JoinPayload
|
||||
{
|
||||
/// <summary>Public hostname the joining patcher rewrites
|
||||
/// <c>*.rec.net</c> URIs to. Cloudflare Tunnel typically gives
|
||||
/// <c>random-words.trycloudflare.com</c>.</summary>
|
||||
/// <summary>Hostname the joining patcher rewrites <c>*.rec.net</c>
|
||||
/// URIs to. Default Localtunnel mode hands out
|
||||
/// <c><random>.loca.lt</c>; LAN mode uses an <c>sslip.io</c>
|
||||
/// name that resolves to the host's private IP.</summary>
|
||||
[JsonPropertyName("host")] public string Host { get; set; } = "";
|
||||
|
||||
/// <summary>Version key from versions.json. Tells the joiner which
|
||||
@@ -65,4 +67,8 @@ public sealed class JoinPayload
|
||||
/// <summary>Server name shown in the joiner's "you're about to
|
||||
/// connect to ..." preview.</summary>
|
||||
[JsonPropertyName("n")] public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>True when all RecNet services are exposed under one
|
||||
/// public origin using /__dn/{service}/ path prefixes.</summary>
|
||||
[JsonPropertyName("so")] public bool SingleOrigin { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace DorkNet.Launcher.Backend;
|
||||
|
||||
/// <summary>Minimal localhost HTTP name-server for the patched client.
|
||||
/// The patcher points the hardcoded RecNet bootstrap URL at
|
||||
/// http://127.0.0.1:80; this listener answers that request with the
|
||||
/// service URL map for the active Easy Launcher session.</summary>
|
||||
public sealed class LauncherNameServer : IAsyncDisposable
|
||||
{
|
||||
private TcpListener? _listener;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _loop;
|
||||
private string _publicBaseUrl = "";
|
||||
|
||||
public void Start(string publicBaseUrl)
|
||||
{
|
||||
_publicBaseUrl = SingleOriginServiceMap.NormalizeBaseUrl(publicBaseUrl);
|
||||
if (_listener is not null) return;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_listener = new TcpListener(IPAddress.Loopback, 80);
|
||||
_listener.Start();
|
||||
_loop = Task.Run(() => AcceptLoopAsync(_cts.Token));
|
||||
}
|
||||
|
||||
public void Update(string publicBaseUrl)
|
||||
=> _publicBaseUrl = SingleOriginServiceMap.NormalizeBaseUrl(publicBaseUrl);
|
||||
|
||||
private async Task AcceptLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested && _listener is not null)
|
||||
{
|
||||
TcpClient client;
|
||||
try { client = await _listener.AcceptTcpClientAsync(ct); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch { if (ct.IsCancellationRequested) break; else continue; }
|
||||
_ = Task.Run(() => HandleAsync(client, ct), ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleAsync(TcpClient client, CancellationToken ct)
|
||||
{
|
||||
using var clientHandle = client;
|
||||
try
|
||||
{
|
||||
await using var stream = client.GetStream();
|
||||
var buffer = new byte[2048];
|
||||
_ = await stream.ReadAsync(buffer, ct);
|
||||
|
||||
var body = SingleOriginServiceMap.ToJson(_publicBaseUrl);
|
||||
var bodyBytes = Encoding.UTF8.GetBytes(body);
|
||||
var header =
|
||||
"HTTP/1.1 200 OK\r\n" +
|
||||
"Content-Type: application/json; charset=utf-8\r\n" +
|
||||
$"Content-Length: {bodyBytes.Length}\r\n" +
|
||||
"Cache-Control: no-store\r\n" +
|
||||
"Connection: close\r\n\r\n";
|
||||
var headerBytes = Encoding.ASCII.GetBytes(header);
|
||||
await stream.WriteAsync(headerBytes, ct);
|
||||
await stream.WriteAsync(bodyBytes, ct);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try { _cts?.Cancel(); } catch { }
|
||||
try { _listener?.Stop(); } catch { }
|
||||
if (_loop is not null)
|
||||
{
|
||||
try { await _loop; } catch { }
|
||||
}
|
||||
_cts?.Dispose();
|
||||
_listener = null;
|
||||
_cts = null;
|
||||
_loop = null;
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,15 @@ using System.Net.Sockets;
|
||||
namespace DorkNet.Launcher.Backend;
|
||||
|
||||
/// <summary>Finds the machine's preferred LAN IPv4 address for
|
||||
/// "local network" hosting mode. Skip Cloudflare entirely, hand
|
||||
/// "local network" hosting mode. Skip public tunnels entirely, hand
|
||||
/// out an sslip.io hostname derived from the LAN IP in the join code,
|
||||
/// and players on the same network connect direct.
|
||||
///
|
||||
/// <para>Selection priority: an "Up" non-loopback network interface
|
||||
/// with a private (RFC1918) IPv4 address. Falls back to 127.0.0.1
|
||||
/// if nothing usable is found — works for solo testing on one
|
||||
/// machine but joiners need either a real LAN address or
|
||||
/// Cloudflare mode.</para></summary>
|
||||
/// machine but joiners need either a real LAN address or one of the
|
||||
/// tunnel modes (Localtunnel / Tunnelto).</para></summary>
|
||||
public static class LocalNetwork
|
||||
{
|
||||
private const string SslipDomain = "sslip.io";
|
||||
|
||||
@@ -50,12 +50,54 @@ public static class LocalOverrides
|
||||
var path = Environment.GetEnvironmentVariable(ReleasesEnvVar);
|
||||
if (!string.IsNullOrEmpty(path)) return Directory.Exists(path) ? path : null;
|
||||
|
||||
var sideBySide = Path.Combine(AppContext.BaseDirectory, "local-releases");
|
||||
if (Directory.Exists(sideBySide)) return sideBySide;
|
||||
var sideBySide = FindSideBySideLocalReleases();
|
||||
if (sideBySide is not null) return sideBySide;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindSideBySideLocalReleases()
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var direct = Path.Combine(baseDir, "local-releases");
|
||||
if (LooksLikeLocalReleasesRoot(direct)) return direct;
|
||||
|
||||
// Be forgiving when users extract the zip into the wrong level:
|
||||
// DorkNet\local-releases\local-releases\...
|
||||
var doubled = Path.Combine(direct, "local-releases");
|
||||
if (LooksLikeLocalReleasesRoot(doubled)) return doubled;
|
||||
|
||||
// Windows Explorer's "Extract All" often creates a folder named
|
||||
// after the zip, then places local-releases inside it.
|
||||
try
|
||||
{
|
||||
foreach (var dir in Directory.GetDirectories(baseDir))
|
||||
{
|
||||
var nested = Path.Combine(dir, "local-releases");
|
||||
if (LooksLikeLocalReleasesRoot(nested)) return nested;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool LooksLikeLocalReleasesRoot(string path)
|
||||
{
|
||||
if (!Directory.Exists(path)) return false;
|
||||
try
|
||||
{
|
||||
return Directory.GetDirectories(path)
|
||||
.Any(versionDir =>
|
||||
Directory.Exists(Path.Combine(versionDir, "server")) ||
|
||||
Directory.Exists(Path.Combine(versionDir, "patcher")));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the local server-dir for <paramref name="versionKey"/>
|
||||
/// if the releases-root override is set and the subfolder exists.
|
||||
/// Convention: <c>{root}/{version_key}/server/</c>.</summary>
|
||||
@@ -86,8 +128,8 @@ public static class LocalOverrides
|
||||
var parts = new List<string>();
|
||||
Describe(ManifestEnvVar, isDir: false, parts);
|
||||
Describe(ReleasesEnvVar, isDir: true, parts);
|
||||
var sideBySide = Path.Combine(AppContext.BaseDirectory, "local-releases");
|
||||
if (Directory.Exists(sideBySide))
|
||||
var sideBySide = FindSideBySideLocalReleases();
|
||||
if (sideBySide is not null)
|
||||
parts.Add($"{ReleasesEnvVar} -> {sideBySide} [side-by-side fallback]");
|
||||
return string.Join("\n", parts);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Launcher.Backend;
|
||||
|
||||
public static class LocaltunnelInstaller
|
||||
{
|
||||
private const string LatestReleaseApi =
|
||||
"https://api.github.com/repos/angelobreuer/localtunnel.net/releases/latest";
|
||||
|
||||
private static readonly HttpClient Http = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(3),
|
||||
};
|
||||
|
||||
static LocaltunnelInstaller()
|
||||
{
|
||||
Http.DefaultRequestHeaders.UserAgent.ParseAdd("DorkNet-Launcher/1.0");
|
||||
}
|
||||
|
||||
public static async Task<string?> TryEnsureAsync(
|
||||
IProgress<DownloadProgress>? progress = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var existing = FindExisting();
|
||||
if (existing is not null) return existing;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(AppPaths.LocalRoot);
|
||||
var release = await Http.GetFromJsonAsync<GitHubRelease>(LatestReleaseApi, ct);
|
||||
var asset = release?.Assets?
|
||||
.Where(a => !string.IsNullOrEmpty(a.BrowserDownloadUrl))
|
||||
.OrderByDescending(a => ScoreAsset(a.Name ?? ""))
|
||||
.FirstOrDefault(a => ScoreAsset(a.Name ?? "") > 0);
|
||||
if (asset is null) return null;
|
||||
|
||||
var zipPath = Path.Combine(AppPaths.LocalRoot, $"localtunnel-{Guid.NewGuid():N}.zip");
|
||||
await DownloadAsync(asset.BrowserDownloadUrl!, zipPath, progress, ct);
|
||||
var extractDir = Path.Combine(AppPaths.LocalRoot, "localtunnel.net");
|
||||
if (Directory.Exists(extractDir)) Directory.Delete(extractDir, recursive: true);
|
||||
Directory.CreateDirectory(extractDir);
|
||||
ZipFile.ExtractToDirectory(zipPath, extractDir);
|
||||
File.Delete(zipPath);
|
||||
|
||||
return Directory.GetFiles(extractDir, OperatingSystem.IsWindows() ? "localtunnel.exe" : "localtunnel",
|
||||
SearchOption.AllDirectories)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindExisting()
|
||||
{
|
||||
var name = OperatingSystem.IsWindows() ? "localtunnel.exe" : "localtunnel";
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, name),
|
||||
Path.Combine(AppPaths.LocalRoot, name),
|
||||
Path.Combine(AppPaths.LocalRoot, "localtunnel.net", name),
|
||||
};
|
||||
foreach (var candidate in candidates)
|
||||
if (File.Exists(candidate)) return candidate;
|
||||
|
||||
return Directory.Exists(Path.Combine(AppPaths.LocalRoot, "localtunnel.net"))
|
||||
? Directory.GetFiles(Path.Combine(AppPaths.LocalRoot, "localtunnel.net"), name, SearchOption.AllDirectories)
|
||||
.FirstOrDefault()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int ScoreAsset(string name)
|
||||
{
|
||||
var n = name.ToLowerInvariant();
|
||||
if (!n.EndsWith(".zip")) return 0;
|
||||
if (OperatingSystem.IsWindows() && n.Contains("win") && n.Contains("x64")) return 100;
|
||||
if (OperatingSystem.IsLinux() && n.Contains("linux") && n.Contains("x64")) return 100;
|
||||
if (OperatingSystem.IsMacOS() && (n.Contains("osx") || n.Contains("mac")) && n.Contains("x64")) return 100;
|
||||
if (OperatingSystem.IsWindows() && n.Contains("win")) return 50;
|
||||
return n.Contains("localtunnel") ? 10 : 0;
|
||||
}
|
||||
|
||||
private static async Task DownloadAsync(
|
||||
string url,
|
||||
string destPath,
|
||||
IProgress<DownloadProgress>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var resp = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var total = resp.Content.Headers.ContentLength ?? 0;
|
||||
await using var src = await resp.Content.ReadAsStreamAsync(ct);
|
||||
await using var dst = File.Create(destPath);
|
||||
var buffer = new byte[128 * 1024];
|
||||
long read = 0;
|
||||
int n;
|
||||
while ((n = await src.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
await dst.WriteAsync(buffer.AsMemory(0, n), ct);
|
||||
read += n;
|
||||
progress?.Report(new DownloadProgress(read, total));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class GitHubRelease
|
||||
{
|
||||
[JsonPropertyName("assets")] public List<GitHubAsset>? Assets { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GitHubAsset
|
||||
{
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
[JsonPropertyName("browser_download_url")] public string? BrowserDownloadUrl { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DorkNet.Launcher.Backend;
|
||||
|
||||
/// <summary>Runs a localtunnel client. Prefer angelobreuer/localtunnel.net
|
||||
/// because it is a native .NET CLI and does not require Node.js; fall
|
||||
/// back to npx localtunnel when users already have Node on PATH.</summary>
|
||||
public sealed class LocaltunnelTunnel : IAsyncDisposable
|
||||
{
|
||||
private Process? _process;
|
||||
private TaskCompletionSource<string>? _urlReady;
|
||||
private readonly object _outputLock = new();
|
||||
private readonly Queue<string> _recentOutput = new();
|
||||
|
||||
public string? PublicUrl { get; private set; }
|
||||
|
||||
public async Task<string> StartAsync(int localPort, CancellationToken ct = default)
|
||||
{
|
||||
var executable = await LocaltunnelInstaller.TryEnsureAsync(ct: ct);
|
||||
LocaltunnelKind kind;
|
||||
if (executable is not null)
|
||||
{
|
||||
kind = LocaltunnelKind.DotNet;
|
||||
}
|
||||
else
|
||||
{
|
||||
executable = ResolveExecutable(out kind);
|
||||
}
|
||||
_urlReady = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
if (kind == LocaltunnelKind.DotNet)
|
||||
{
|
||||
psi.ArgumentList.Add("--no-dashboard");
|
||||
psi.ArgumentList.Add("--host");
|
||||
psi.ArgumentList.Add("127.0.0.1");
|
||||
psi.ArgumentList.Add("--port");
|
||||
psi.ArgumentList.Add(localPort.ToString());
|
||||
psi.ArgumentList.Add("http");
|
||||
}
|
||||
else
|
||||
{
|
||||
psi.ArgumentList.Add("--yes");
|
||||
psi.ArgumentList.Add("localtunnel");
|
||||
psi.ArgumentList.Add("--port");
|
||||
psi.ArgumentList.Add(localPort.ToString());
|
||||
psi.ArgumentList.Add("--local-host");
|
||||
psi.ArgumentList.Add("127.0.0.1");
|
||||
}
|
||||
|
||||
_process = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("Failed to start localtunnel through npx.");
|
||||
_process.EnableRaisingEvents = true;
|
||||
_process.Exited += (_, _) =>
|
||||
{
|
||||
if (_urlReady is null || _urlReady.Task.IsCompleted) return;
|
||||
_urlReady.TrySetException(new InvalidOperationException(
|
||||
"localtunnel exited before reporting a public URL." + FormatRecentOutput()));
|
||||
};
|
||||
_process.OutputDataReceived += (_, e) => ParseLine(e.Data);
|
||||
_process.ErrorDataReceived += (_, e) => ParseLine(e.Data);
|
||||
_process.BeginOutputReadLine();
|
||||
_process.BeginErrorReadLine();
|
||||
|
||||
var completed = await Task.WhenAny(_urlReady.Task, Task.Delay(TimeSpan.FromSeconds(60), ct));
|
||||
if (completed != _urlReady.Task)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
throw new TimeoutException("localtunnel did not report a public URL within 60 seconds." + FormatRecentOutput());
|
||||
}
|
||||
|
||||
PublicUrl = await _urlReady.Task;
|
||||
return PublicUrl;
|
||||
}
|
||||
|
||||
private static string ResolveExecutable(out LocaltunnelKind kind)
|
||||
{
|
||||
var ltName = OperatingSystem.IsWindows() ? "localtunnel.exe" : "localtunnel";
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, ltName),
|
||||
Path.Combine(AppPaths.LocalRoot, ltName),
|
||||
};
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!File.Exists(candidate)) continue;
|
||||
kind = LocaltunnelKind.DotNet;
|
||||
return candidate;
|
||||
}
|
||||
var ltOnPath = Environment.GetEnvironmentVariable("PATH")?
|
||||
.Split(Path.PathSeparator)
|
||||
.Select(p => Path.Combine(p.Trim(), ltName))
|
||||
.FirstOrDefault(File.Exists);
|
||||
if (ltOnPath is not null)
|
||||
{
|
||||
kind = LocaltunnelKind.DotNet;
|
||||
return ltOnPath;
|
||||
}
|
||||
|
||||
var name = OperatingSystem.IsWindows() ? "npx.cmd" : "npx";
|
||||
var onPath = Environment.GetEnvironmentVariable("PATH")?
|
||||
.Split(Path.PathSeparator)
|
||||
.Select(p => Path.Combine(p.Trim(), name))
|
||||
.FirstOrDefault(File.Exists);
|
||||
if (onPath is not null)
|
||||
{
|
||||
kind = LocaltunnelKind.Npx;
|
||||
return onPath;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(
|
||||
"localtunnel was not found. Put angelobreuer/localtunnel.net's localtunnel.exe next to dorknet.exe, or install Node.js LTS for the npx fallback.");
|
||||
}
|
||||
|
||||
private static readonly Regex UrlRegex = new(
|
||||
@"https://[a-z0-9][a-z0-9\-]*\.(?:loca\.lt|localtunnel\.me)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private void ParseLine(string? line)
|
||||
{
|
||||
if (line is null) return;
|
||||
lock (_outputLock)
|
||||
{
|
||||
_recentOutput.Enqueue(line);
|
||||
while (_recentOutput.Count > 8) _recentOutput.Dequeue();
|
||||
}
|
||||
if (_urlReady is null || _urlReady.Task.IsCompleted) return;
|
||||
var match = UrlRegex.Match(line);
|
||||
if (match.Success) _urlReady.TrySetResult(match.Value);
|
||||
}
|
||||
|
||||
private string FormatRecentOutput()
|
||||
{
|
||||
lock (_outputLock)
|
||||
{
|
||||
return _recentOutput.Count == 0
|
||||
? ""
|
||||
: " Last localtunnel output: " + string.Join(" | ", _recentOutput);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_process is null) return;
|
||||
try
|
||||
{
|
||||
if (!_process.HasExited) _process.Kill(entireProcessTree: true);
|
||||
await _process.WaitForExitAsync();
|
||||
}
|
||||
catch { }
|
||||
_process.Dispose();
|
||||
}
|
||||
|
||||
private enum LocaltunnelKind { DotNet, Npx }
|
||||
}
|
||||
@@ -32,7 +32,11 @@ public sealed class ServerProcess : IAsyncDisposable
|
||||
throw new InvalidOperationException("A server is already running; stop it first.");
|
||||
|
||||
var exe = FindServerExecutable(serverDir);
|
||||
ApplySqliteCompatibilityPatches(serverDir);
|
||||
// db lives under %APPDATA%\DorkNet so it survives reinstalls of
|
||||
// the server cache dir (which sits under %LOCALAPPDATA%).
|
||||
var dbPath = Path.Combine(AppPaths.RoamingRoot, "dorknet.db");
|
||||
MigrateLegacyDbIfPresent(serverDir, dbPath);
|
||||
ApplySqliteCompatibilityPatches(dbPath);
|
||||
StdoutLogPath = Path.Combine(AppPaths.LogsRoot, $"server-{DateTime.UtcNow:yyyyMMdd-HHmmss}.log");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
@@ -45,23 +49,30 @@ public sealed class ServerProcess : IAsyncDisposable
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.Environment["ASPNETCORE_ENVIRONMENT"] = "Development";
|
||||
psi.Environment["ASPNETCORE_URLS"] = hostingMode == HostingMode.LocalNetwork
|
||||
var singleOriginMode = hostingMode == HostingMode.SingleOriginTunnel;
|
||||
var directMode = hostingMode is HostingMode.LocalNetwork or HostingMode.Internet;
|
||||
psi.Environment["ASPNETCORE_URLS"] = directMode
|
||||
? $"http://0.0.0.0:{LanPort}"
|
||||
: $"http://127.0.0.1:{DefaultLocalPort}";
|
||||
psi.Environment["Database__Provider"] = "sqlite";
|
||||
psi.Environment["Database__SqlitePath"] = dbPath;
|
||||
psi.Environment["Jwt__Secret"] = EnsureJwtSecret();
|
||||
psi.Environment["Photon__AppId"] = state.PhotonAppId;
|
||||
psi.Environment["Photon__VoiceAppId"] =
|
||||
string.IsNullOrEmpty(state.PhotonVoiceAppId) ? state.PhotonAppId : state.PhotonVoiceAppId;
|
||||
psi.Environment["Photon__CloudRegion"] = state.PhotonRegion;
|
||||
psi.Environment["Domain__Apex"] = apex;
|
||||
psi.Environment["Domain__Scheme"] = hostingMode == HostingMode.LocalNetwork ? "http" : "https";
|
||||
psi.Environment["Domain__Scheme"] = directMode ? "http" : "https";
|
||||
psi.Environment["Domain__Port"] = "";
|
||||
if (singleOriginMode)
|
||||
psi.Environment["Domain__SingleOriginBaseUrl"] = $"https://{apex}";
|
||||
|
||||
AppendLog($"[launcher] serverDir={serverDir}");
|
||||
AppendLog($"[launcher] exe={exe}");
|
||||
AppendLog($"[launcher] urls={psi.Environment["ASPNETCORE_URLS"]}");
|
||||
AppendLog($"[launcher] domain apex={apex}, scheme={psi.Environment["Domain__Scheme"]}, port={psi.Environment["Domain__Port"]}");
|
||||
if (singleOriginMode)
|
||||
AppendLog($"[launcher] single origin={psi.Environment["Domain__SingleOriginBaseUrl"]}");
|
||||
|
||||
_process = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||
_process.OutputDataReceived += (_, e) => AppendLog(e.Data);
|
||||
@@ -128,9 +139,8 @@ public sealed class ServerProcess : IAsyncDisposable
|
||||
$"No server binary found in {serverDir}. The release artifact may be corrupt.");
|
||||
}
|
||||
|
||||
private static void ApplySqliteCompatibilityPatches(string serverDir)
|
||||
private static void ApplySqliteCompatibilityPatches(string dbPath)
|
||||
{
|
||||
var dbPath = Path.Combine(serverDir, "data", "dorknet.db");
|
||||
if (!File.Exists(dbPath)) return;
|
||||
|
||||
using var conn = new SqliteConnection($"Data Source={dbPath}");
|
||||
@@ -139,6 +149,34 @@ public sealed class ServerProcess : IAsyncDisposable
|
||||
@"""HiddenFromBrowse"" INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
/// <summary>If an older launcher build left the SQLite db under
|
||||
/// <c><serverDir>\data\dorknet.db</c> (where the server defaulted
|
||||
/// before <see cref="Database__SqlitePath"/> took over), copy it to
|
||||
/// the new <see cref="AppPaths.RoamingRoot"/> location so existing
|
||||
/// players, rooms, and dorms survive the move. Best-effort — never
|
||||
/// throws, never overwrites a db that's already at the new path.</summary>
|
||||
private static void MigrateLegacyDbIfPresent(string serverDir, string newDbPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(newDbPath)) return;
|
||||
var legacy = Path.Combine(serverDir, "data", "dorknet.db");
|
||||
if (!File.Exists(legacy)) return;
|
||||
|
||||
var destDir = Path.GetDirectoryName(newDbPath);
|
||||
if (!string.IsNullOrEmpty(destDir)) Directory.CreateDirectory(destDir);
|
||||
File.Copy(legacy, newDbPath);
|
||||
// SQLite WAL/SHM sidecars live next to the .db — copy them
|
||||
// too so the move doesn't drop in-flight wal frames.
|
||||
foreach (var suffix in new[] { "-wal", "-shm" })
|
||||
{
|
||||
var src = legacy + suffix;
|
||||
if (File.Exists(src)) File.Copy(src, newDbPath + suffix, overwrite: true);
|
||||
}
|
||||
}
|
||||
catch { /* best-effort — fresh db will be created on first server boot */ }
|
||||
}
|
||||
|
||||
private static void AddSqliteColumnIfMissing(
|
||||
SqliteConnection conn,
|
||||
string table,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Launcher.Backend;
|
||||
|
||||
public static class SingleOriginServiceMap
|
||||
{
|
||||
private static readonly (string Service, string Prefix)[] Services =
|
||||
[
|
||||
("WWW", "www"),
|
||||
("API", "api"),
|
||||
("Accounts", "accounts"),
|
||||
("Auth", "auth"),
|
||||
("BugReporting", "bugreporting"),
|
||||
("Cards", "cards"),
|
||||
("CDN", "cdn"),
|
||||
("Chat", "chat"),
|
||||
("Clubs", "clubs"),
|
||||
("CMS", "cms"),
|
||||
("Commerce", "commerce"),
|
||||
("Data", "data"),
|
||||
("DataCollection", "datacollection"),
|
||||
("Discovery", "discovery"),
|
||||
("Econ", "econ"),
|
||||
("GameLogs", "gamelogs"),
|
||||
("Geo", "geo"),
|
||||
("Images", "img"),
|
||||
("Leaderboard", "leaderboard"),
|
||||
("Link", "link"),
|
||||
("Lists", "lists"),
|
||||
("Matchmaking", "match"),
|
||||
("Moderation", "moderation"),
|
||||
("NameServer", "ns"),
|
||||
("Notifications", "notify"),
|
||||
("PlatformNotifications", "platformnotifications"),
|
||||
("PlayerSettings", "playersettings"),
|
||||
("RoomComments", "roomcomments"),
|
||||
("RoomieIntegrations", "roomieintegrations"),
|
||||
("Rooms", "rooms"),
|
||||
("Storage", "storage"),
|
||||
("Strings", "strings"),
|
||||
("StringsCDN", "strings-cdn"),
|
||||
("Studio", "studio"),
|
||||
("Thorn", "thorn"),
|
||||
("Videos", "videos"),
|
||||
];
|
||||
|
||||
public static Dictionary<string, string> Build(string publicBaseUrl)
|
||||
{
|
||||
var baseUrl = NormalizeBaseUrl(publicBaseUrl);
|
||||
return Services.ToDictionary(
|
||||
x => x.Service,
|
||||
x => $"{baseUrl}/__dn/{x.Prefix}",
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string ToJson(string publicBaseUrl)
|
||||
=> JsonSerializer.Serialize(Build(publicBaseUrl));
|
||||
|
||||
public static string NormalizeBaseUrl(string value)
|
||||
{
|
||||
var trimmed = (value ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
throw new ArgumentException("Public tunnel URL is empty.", nameof(value));
|
||||
if (!trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
trimmed = "https://" + trimmed;
|
||||
return trimmed.TrimEnd('/');
|
||||
}
|
||||
}
|
||||
@@ -77,9 +77,13 @@ public static class StartupFlow
|
||||
new StartupStep("Download server binary"),
|
||||
new StartupStep(mode switch
|
||||
{
|
||||
HostingMode.SingleOriginTunnel => "Start localtunnel",
|
||||
HostingMode.LocalNetwork => "Set up local sslip.io address",
|
||||
HostingMode.RemoteWildcard => "Set up Tunnelto tunnel",
|
||||
_ => "Set up Tunnelto tunnel",
|
||||
HostingMode.RemoteWildcard => "Install / start Tunnelto tunnel",
|
||||
// Internet maps onto SingleOriginTunnel via AppState.Load
|
||||
// migration; keep the arm in case a stale value sneaks in.
|
||||
HostingMode.Internet => "Start localtunnel",
|
||||
_ => "Start localtunnel",
|
||||
}),
|
||||
new StartupStep("Start the server"),
|
||||
new StartupStep("Download client patcher"),
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.IO.Compression;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Launcher.Backend;
|
||||
@@ -72,8 +73,16 @@ public sealed class TunneltoInstaller
|
||||
"`cargo install tunnelto` (or grab a build from github.com/agrinman/tunnelto/releases).");
|
||||
}
|
||||
|
||||
var release = await Http.GetFromJsonAsync<GitHubRelease>(ReleasesApi, ct)
|
||||
?? throw new InvalidOperationException("Couldn't reach github.com/agrinman/tunnelto/releases.");
|
||||
GitHubRelease release;
|
||||
try
|
||||
{
|
||||
release = await Http.GetFromJsonAsync<GitHubRelease>(ReleasesApi, ct)
|
||||
?? throw new InvalidOperationException("Couldn't reach github.com/agrinman/tunnelto/releases.");
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException("Timed out while checking GitHub for the latest Tunnelto release.");
|
||||
}
|
||||
|
||||
var (assetName, kind) = AssetForCurrentPlatform();
|
||||
var asset = release.Assets?.FirstOrDefault(a =>
|
||||
@@ -91,7 +100,14 @@ public sealed class TunneltoInstaller
|
||||
var stagingPath = Path.Combine(AppPaths.LocalRoot, $"tunnelto-staging-{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
await DownloadAsync(asset.BrowserDownloadUrl, stagingPath, progress, ct);
|
||||
try
|
||||
{
|
||||
await DownloadAsync(asset.BrowserDownloadUrl, stagingPath, progress, ct);
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException("Timed out while downloading Tunnelto from GitHub.");
|
||||
}
|
||||
switch (kind)
|
||||
{
|
||||
case AssetKind.RawExe:
|
||||
@@ -184,6 +200,7 @@ public sealed class TunneltoInstaller
|
||||
/// <summary>Mark a file as executable on Unix systems. Uses
|
||||
/// <see cref="File.SetUnixFileMode"/> (available since .NET 7).
|
||||
/// No-op on Windows since the NTFS execute bit doesn't apply.</summary>
|
||||
[UnsupportedOSPlatform("windows")]
|
||||
private static void TryChmodExec(string path)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -14,6 +14,8 @@ public sealed class TunneltoTunnel : IAsyncDisposable
|
||||
{
|
||||
private Process? _process;
|
||||
private TaskCompletionSource<string>? _urlReady;
|
||||
private readonly object _outputLock = new();
|
||||
private readonly Queue<string> _recentOutput = new();
|
||||
public string? PublicUrl { get; private set; }
|
||||
|
||||
public async Task<string> StartAsync(
|
||||
@@ -36,20 +38,41 @@ public sealed class TunneltoTunnel : IAsyncDisposable
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add("add");
|
||||
psi.ArgumentList.Add(tunnelHost);
|
||||
psi.ArgumentList.Add("--host");
|
||||
psi.ArgumentList.Add("127.0.0.1");
|
||||
psi.ArgumentList.Add("--port");
|
||||
psi.ArgumentList.Add(localPort.ToString());
|
||||
if (TryGetTunneltoSubdomain(tunnelHost, out var subdomain))
|
||||
{
|
||||
psi.ArgumentList.Add("--subdomain");
|
||||
psi.ArgumentList.Add(subdomain);
|
||||
}
|
||||
|
||||
_process = Process.Start(psi) ??
|
||||
throw new InvalidOperationException("Failed to spawn tunnelto.exe");
|
||||
_process.EnableRaisingEvents = true;
|
||||
_process.Exited += (_, _) =>
|
||||
{
|
||||
if (_urlReady is null || _urlReady.Task.IsCompleted) return;
|
||||
_urlReady.TrySetException(new InvalidOperationException(
|
||||
"Tunnelto exited before reporting a public URL." + FormatRecentOutput()));
|
||||
};
|
||||
_process.OutputDataReceived += (_, e) => ParseLine(e.Data);
|
||||
_process.ErrorDataReceived += (_, e) => ParseLine(e.Data);
|
||||
_process.BeginOutputReadLine();
|
||||
_process.BeginErrorReadLine();
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(45));
|
||||
PublicUrl = await _urlReady.Task.WaitAsync(timeoutCts.Token);
|
||||
var readyTask = _urlReady.Task;
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(45), ct);
|
||||
var completed = await Task.WhenAny(readyTask, timeoutTask);
|
||||
if (completed == timeoutTask)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
throw new TimeoutException(
|
||||
"Tunnelto didn't report a public URL within 45 seconds." + FormatRecentOutput());
|
||||
}
|
||||
|
||||
PublicUrl = await readyTask;
|
||||
return PublicUrl;
|
||||
}
|
||||
|
||||
@@ -74,6 +97,16 @@ public sealed class TunneltoTunnel : IAsyncDisposable
|
||||
return $"dorknet-{token}.tunnelto.me";
|
||||
}
|
||||
|
||||
private static bool TryGetTunneltoSubdomain(string host, out string subdomain)
|
||||
{
|
||||
const string suffix = ".tunnelto.me";
|
||||
subdomain = "";
|
||||
if (!host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
subdomain = host[..^suffix.Length];
|
||||
return !string.IsNullOrWhiteSpace(subdomain);
|
||||
}
|
||||
|
||||
private static string ResolveExecutable()
|
||||
{
|
||||
var isWindows = OperatingSystem.IsWindows();
|
||||
@@ -112,11 +145,27 @@ public sealed class TunneltoTunnel : IAsyncDisposable
|
||||
|
||||
private void ParseLine(string? line)
|
||||
{
|
||||
if (line is null || _urlReady is null || _urlReady.Task.IsCompleted) return;
|
||||
if (line is null) return;
|
||||
lock (_outputLock)
|
||||
{
|
||||
_recentOutput.Enqueue(line);
|
||||
while (_recentOutput.Count > 8) _recentOutput.Dequeue();
|
||||
}
|
||||
|
||||
if (_urlReady is null || _urlReady.Task.IsCompleted) return;
|
||||
var match = UrlRegex.Match(line);
|
||||
if (match.Success) _urlReady.TrySetResult(match.Value);
|
||||
}
|
||||
|
||||
private string FormatRecentOutput()
|
||||
{
|
||||
lock (_outputLock)
|
||||
{
|
||||
if (_recentOutput.Count == 0) return "";
|
||||
return " Last Tunnelto output: " + string.Join(" | ", _recentOutput);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_process is null) return;
|
||||
|
||||
Reference in New Issue
Block a user