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:
Alexa
2026-05-28 01:39:38 +03:00
parent 7852b410ee
commit f5ad4b59ef
30 changed files with 942 additions and 184 deletions
+20 -8
View File
@@ -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
+6 -3
View File
@@ -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)}");
}
+22 -1
View File
@@ -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(
+11 -5
View File
@@ -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>&lt;random&gt;.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; }
}
+81
View File
@@ -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;
}
}
+3 -3
View File
@@ -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";
+46 -4
View File
@@ -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);
}
+120
View File
@@ -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; }
}
}
+164
View File
@@ -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 }
}
+43 -5
View File
@@ -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>&lt;serverDir&gt;\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('/');
}
}
+6 -2
View File
@@ -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"),
+20 -3
View File
@@ -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
+55 -6
View File
@@ -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;