Files
DorkNet/cli/DorkNet.ServerCli/Program.cs
T
Alexa cfd5724859 fix(cli): exit 0 on --help, handle parse errors cleanly
--help printed usage but returned exit code 1, failing the CI smoke test. Treat --help / -h / no-args as a successful usage print (exit 0), and catch parser ArgumentExceptions so a missing value or bad --mode exits 1 with a message instead of an unhandled crash.
2026-06-16 00:55:41 +03:00

318 lines
14 KiB
C#

using System.Diagnostics;
using DorkNet.Launcher.Backend;
namespace DorkNet.ServerCli;
/// <summary>Cross-platform host-side CLI for DorkNet. Drives the same
/// server + tunnel pipeline the Windows GUI launcher uses, but headless,
/// so Linux and macOS hosts can serve Windows joiners without needing
/// the WPF launcher (or any GUI). Joiners still use the Windows GUI
/// launcher to apply the patch to their copy of Rec Room.
///
/// <para>Run <c>dorknet-server --help</c> for the option list.</para></summary>
public static class Program
{
public static async Task<int> Main(string[] args)
{
// --help / -h (and a bare invocation) print usage and exit cleanly.
if (args.Length == 0 || args.Contains("--help") || args.Contains("-h"))
{
PrintHelp();
return 0;
}
var parsed = ParseArgs(args);
if (parsed is null) return 1; // ParseArgs already wrote the error.
// Validate required values up front so the user sees the
// failure before any download / process spin-up.
if (string.IsNullOrWhiteSpace(parsed.PhotonAppId))
{
Console.Error.WriteLine("error: --photon-id is required.");
Console.Error.WriteLine(" Grab one free at https://dashboard.photonengine.com");
return 2;
}
AppPaths.EnsureDirectoriesExist();
var state = BuildState(parsed);
Console.WriteLine($"DorkNet server CLI · v{typeof(Program).Assembly.GetName().Version}");
Console.WriteLine($" mode: {parsed.Mode}");
Console.WriteLine($" server: {parsed.ServerName}");
Console.WriteLine($" photon: {Redact(parsed.PhotonAppId)} ({parsed.Region})");
if (parsed.Mode is HostingMode.Internet or HostingMode.SingleOriginTunnel)
Console.WriteLine(" network: Localtunnel");
if (parsed.Mode == HostingMode.RemoteWildcard)
Console.WriteLine($" apex: {state.RemoteWildcardApex}");
Console.WriteLine();
var manifest = await new VersionsManifest().FetchAsync();
if (manifest is null)
{
Console.Error.WriteLine("error: couldn't fetch versions.json (offline?).");
Console.Error.WriteLine(" Set DORKNET_LOCAL_MANIFEST=/path/to/versions.json to override.");
return 3;
}
var version = manifest.Branches
.FirstOrDefault(b => b.VersionKey.Equals(parsed.VersionKey, StringComparison.OrdinalIgnoreCase));
if (version is null)
{
Console.Error.WriteLine($"error: version '{parsed.VersionKey}' not found in manifest.");
Console.Error.WriteLine(" Supported versions:");
foreach (var b in manifest.Branches.Where(b => b.Supported))
Console.Error.WriteLine($" {b.VersionKey} (Rec Room {b.ClientBuild})");
return 4;
}
var releases = new ReleaseDownloader();
var server = new ServerProcess();
IAsyncDisposable? tunnel = null;
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
try
{
// Step 1 — server binary
string serverDir;
if (!string.IsNullOrEmpty(parsed.ServerDir))
{
if (!Directory.Exists(parsed.ServerDir))
{
Console.Error.WriteLine($"error: --server-dir not found: {parsed.ServerDir}");
return 5;
}
serverDir = parsed.ServerDir;
Console.WriteLine($"[server] using local build: {serverDir}");
}
else
{
Console.WriteLine("[server] downloading…");
var progress = new Progress<DownloadProgress>(p =>
{
if (p.TotalBytes > 0)
Console.Write($"\r {p.Fraction * 100,5:F1}% ({p.BytesRead / (1024.0 * 1024):F1} / {p.TotalBytes / (1024.0 * 1024):F1} MB) ");
});
serverDir = await releases.EnsureServerAsync(version, progress, cts.Token);
Console.WriteLine($"\n[server] cached at {serverDir}");
}
// Step 2 — tunnel (or LAN)
string apex;
if (parsed.Mode == HostingMode.LocalNetwork)
{
var lan = LocalNetwork.GetLanAddress();
apex = lan.Host;
Console.WriteLine($"[lan] bound on {lan.Ip} (apex={apex})");
}
else if (parsed.Mode == HostingMode.RemoteWildcard)
{
Console.WriteLine("[tunnel] checking tunnelto…");
await new TunneltoInstaller().EnsureInstalledAsync(
new Progress<DownloadProgress>(p =>
{
if (p.TotalBytes > 0)
Console.Write($"\r {p.Fraction * 100,5:F1}% ({p.BytesRead / (1024.0 * 1024):F1} / {p.TotalBytes / (1024.0 * 1024):F1} MB) ");
}),
cts.Token);
Console.WriteLine();
Console.WriteLine("[tunnel] starting tunnelto…");
var tt = new TunneltoTunnel();
tunnel = tt;
var publicUrl = await tt.StartAsync(
state.RemoteWildcardApex,
ServerProcess.DefaultLocalPort,
cts.Token);
apex = TunneltoTunnel.NormalizeHost(state.RemoteWildcardApex);
Console.WriteLine($"[tunnel] live at {publicUrl} (apex={apex})");
}
else
{
// Default Localtunnel path. Anonymous public *.loca.lt URL,
// no account, regenerated each run.
Console.WriteLine("[tunnel] starting localtunnel…");
var lt = new LocaltunnelTunnel();
tunnel = lt;
var publicUrl = await lt.StartAsync(ServerProcess.DefaultLocalPort, cts.Token);
apex = new Uri(publicUrl).Host;
Console.WriteLine($"[tunnel] live at {publicUrl} (apex={apex})");
}
// Step 3 — server process
Console.WriteLine("[server] starting…");
await server.StartAsync(serverDir, state, apex, parsed.Mode, cts.Token);
Console.WriteLine($"[server] listening (logs: {server.StdoutLogPath})");
// Step 4 — emit join code
var code = JoinCode.Encode(new JoinPayload
{
Host = apex,
VersionKey = version.VersionKey,
PhotonAppId = state.PhotonAppId,
PhotonVoiceAppId = state.PhotonVoiceAppId,
PhotonRegion = state.PhotonRegion,
Name = state.ServerName,
});
var scheme = parsed.Mode == HostingMode.LocalNetwork ? "http" : "https";
Console.WriteLine();
Console.WriteLine("══════════════════════════════════════════════════════");
Console.WriteLine($" Server live: {parsed.ServerName}");
Console.WriteLine($" Address: {scheme}://{apex}");
Console.WriteLine($" Admin panel: {scheme}://admin.{apex}");
Console.WriteLine(" (first account on a fresh server becomes admin)");
Console.WriteLine();
Console.WriteLine(" Join code (paste this into your friend's launcher):");
Console.WriteLine();
Console.WriteLine($" {code}");
Console.WriteLine();
Console.WriteLine(" Ctrl-C to stop.");
Console.WriteLine("══════════════════════════════════════════════════════");
try { await Task.Delay(Timeout.Infinite, cts.Token); }
catch (OperationCanceledException) { /* clean Ctrl-C */ }
}
catch (Exception ex)
{
Console.Error.WriteLine();
Console.Error.WriteLine($"error: {ex.Message}");
return 10;
}
finally
{
Console.WriteLine();
Console.WriteLine("[shutdown] stopping server…");
try { await server.StopAsync(); } catch { }
if (tunnel is not null) { try { await tunnel.DisposeAsync(); } catch { } }
Console.WriteLine("[shutdown] done.");
}
return 0;
}
/// <summary>Builds an in-memory <see cref="AppState"/> matching
/// the CLI args. The CLI is stateless — it never reads or writes
/// the launcher's state.json, so a Windows + Linux user on the
/// same machine don't fight over the same file.</summary>
private static AppState BuildState(ParsedArgs p) => new()
{
Mode = AppMode.Host,
PhotonAppId = p.PhotonAppId,
PhotonVoiceAppId = p.PhotonVoiceAppId,
PhotonRegion = p.Region,
HostingMode = p.Mode,
RemoteWildcardApex = p.Mode == HostingMode.RemoteWildcard && !string.IsNullOrEmpty(p.Apex)
? TunneltoTunnel.NormalizeHost(p.Apex)
: TunneltoTunnel.GenerateBaseHost(),
ServerName = p.ServerName,
SelectedVersion = p.VersionKey,
SetupComplete = true,
WelcomeSeen = true,
};
/// <summary>Mask all but the last 4 chars of an AppId so it's safe
/// to print to a shared terminal / CI log.</summary>
private static string Redact(string s) =>
string.IsNullOrEmpty(s) || s.Length < 8
? "(missing)"
: new string('*', s.Length - 4) + s[^4..];
// ── Arg parsing ─────────────────────────────────────────────────────
private sealed record ParsedArgs(
string PhotonAppId, string PhotonVoiceAppId, string Region,
HostingMode Mode, string Apex, string ServerName, string VersionKey,
string? ServerDir);
private static ParsedArgs? ParseArgs(string[] args)
{
string photon = "", voice = "", region = "us";
string mode = "localtunnel", apex = "", name = "DorkNet Server";
string versionKey = "march_2020_03_10";
string? serverDir = null;
try
{
for (int i = 0; i < args.Length; i++)
{
string Next()
{
if (i + 1 >= args.Length)
throw new ArgumentException($"missing value for {args[i]}");
return args[++i];
}
switch (args[i])
{
case "--photon-id": photon = Next(); break;
case "--voice-id": voice = Next(); break;
case "--region": region = Next(); break;
case "--mode": mode = Next(); break;
case "--apex": apex = Next(); break;
case "--name": name = Next(); break;
case "--version": versionKey = Next(); break;
case "--server-dir": serverDir = Next(); break;
default:
Console.Error.WriteLine($"error: unknown arg '{args[i]}' (try --help)");
return null;
}
}
HostingMode parsedMode = mode.ToLowerInvariant() switch
{
"localtunnel" or "lt" or "internet" or "tunnel" => HostingMode.SingleOriginTunnel,
"wildcard" or "remote" or "tunnelto" => HostingMode.RemoteWildcard,
"lan" or "local" or "wifi" => HostingMode.LocalNetwork,
_ => throw new ArgumentException($"unknown --mode '{mode}' (try localtunnel, wildcard, lan)"),
};
return new ParsedArgs(photon, voice, region, parsedMode, apex, name, versionKey, serverDir);
}
catch (ArgumentException ex)
{
Console.Error.WriteLine($"error: {ex.Message} (try --help)");
return null;
}
}
private static void PrintHelp()
{
Console.WriteLine("""
dorknet-server — host a private Rec Room server (Linux / macOS / Windows)
USAGE
dorknet-server --photon-id <APPID> [options]
REQUIRED
--photon-id <guid> Photon Realtime AppId (free at
dashboard.photonengine.com)
OPTIONS
--voice-id <guid> Photon Voice AppId. Empty = no voice.
--region <code> Photon cloud region (us | eu | asia | jp |
sa | kr | in | au). Default: us.
--mode <kind> localtunnel (default) — public *.loca.lt
URL, friends anywhere
wildcard — Tunnelto wildcard
base (needs --apex)
lan — same WiFi only,
binds on 0.0.0.0:80
--apex <hostname> Wildcard apex (e.g. dorknet.tunnelto.me).
Required for --mode wildcard.
--name "<text>" Server name shown in the join code.
Default: "DorkNet Server".
--version <key> Rec Room version to host for.
Default: march_2020_03_10.
--server-dir <path> Skip download, use this local server
build. Useful for dev iteration.
EXAMPLES
# Linux host, default Localtunnel mode
dorknet-server --photon-id 00000000-1111-... --name "Sunday games"
# Wildcard base, custom region
dorknet-server --photon-id ... --mode wildcard \
--apex dorknet.example.tunnelto.me --region eu
# LAN-only on the local network
dorknet-server --photon-id ... --mode lan
""");
}
}