-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathProgram.cs
More file actions
336 lines (301 loc) · 16.2 KB
/
Copy pathProgram.cs
File metadata and controls
336 lines (301 loc) · 16.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
using MediaDownloader.Components;
using MediaDownloader.Data;
using MediaDownloader.Services;
using MediaDownloader.Services.Api;
using MediaDownloader.Services.Downloads;
using MediaDownloader.Services.Localization;
using MediaDownloader.Services.Tray;
using MediaDownloader.Services.Updates;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using Serilog;
using Serilog.Events;
// Configured before the host so startup failures (bad config, port binding, DB open) are logged
// too. Console (dev/tray-off) + a rolling daily file — the packaged macOS app has no console once
// launched from Finder/`open`, so the file is the only place logs survive to actually debug it.
// This is a "bootstrap" logger per Serilog's own two-stage pattern: builder.Host.UseSerilog()
// below replaces it with the final logger once configuration is available (needed to read
// Sentry:Dsn from appsettings.json/environment before deciding whether to add that sink).
var isDev = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") == "Development"
|| Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development";
Log.Logger = ConfigureCommonSinks(new LoggerConfiguration(), isDev).CreateLogger();
// Console + rolling file, shared by both the bootstrap logger above and the final one built by
// UseSerilog() below (mutates and returns the same LoggerConfiguration instance it's given).
static LoggerConfiguration ConfigureCommonSinks(LoggerConfiguration config, bool isDev) => config
.MinimumLevel.Is(isDev ? LogEventLevel.Debug : LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
.Enrich.WithProperty("Version", UpdateService.CurrentVersionText)
.WriteTo.Console()
.WriteTo.File(Path.Combine(AppPaths.LogsDirectory, "app-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14,
fileSizeLimitBytes: 10_000_000,
rollOnFileSizeLimit: true);
// Catch exceptions that never make it into a try/catch anywhere else — a crashing background
// thread (e.g. inside DownloadManager's timer callback) would otherwise fail silently.
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception (terminating: {IsTerminating})", e.IsTerminating);
TaskScheduler.UnobservedTaskException += (_, e) =>
{
Log.Error(e.Exception, "Unobserved task exception");
e.SetObserved();
};
AppDomain.CurrentDomain.ProcessExit += (_, _) => Log.CloseAndFlush();
try
{
await RunApp(args);
}
catch (HostAbortedException)
{
// EF Core's design-time tooling (`dotnet ef migrations add`, `database update`,
// `migrations has-pending-model-changes`, …) launches this entry point only to resolve the
// DbContext: it subscribes to the "HostBuilt" diagnostic event, grabs the service provider the
// moment builder.Build() fires, then throws HostAbortedException to stop the app from actually
// running. It's the documented, expected signal — not a crash — so swallow it rather than
// logging Fatal (which shipped a bogus "Application terminated unexpectedly" event to Sentry
// from every `dotnet ef` command run on a dev machine).
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
throw;
}
finally
{
// Unreachable on macOS in tray mode: MacTrayApp.Run blocks forever and the process instead
// exits via Environment.Exit(0) once shutdown completes, which skips finally blocks. That
// path is covered separately by the ProcessExit handler registered above.
Log.CloseAndFlush();
}
async Task RunApp(string[] hostArgs)
{
// Pin the content root to the app's own directory. The default is the *current working
// directory*, which is "/" when macOS launches the .app bundle via Finder/`open` — static
// assets then resolve against /wwwroot and get served as empty 200s.
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
Args = hostArgs,
ContentRootPath = AppContext.BaseDirectory,
});
builder.Host.UseSerilog((context, _, loggerConfiguration) =>
{
// Use the fully-resolved hosting environment now that it exists: it honours --environment and
// launchSettings too, not just the env vars the pre-host bootstrap check (isDev) could see.
var envIsDev = context.HostingEnvironment.IsDevelopment();
ConfigureCommonSinks(loggerConfiguration, envIsDev);
// Optional: ships Error+ events to Sentry for remote crash monitoring. Empty/absent by
// default — set Sentry:Dsn in appsettings.json or the Sentry__Dsn environment variable
// (ASP.NET Core's double-underscore config convention) to enable. A Sentry DSN is a
// write-only ingestion endpoint (not a secret credential
// — Sentry's own docs say it's safe to ship in client binaries), so it's fine to bake into a
// release build; it just shouldn't be assumed to grant any read/account access if it leaks.
var dsn = context.Configuration["Sentry:Dsn"];
if (!string.IsNullOrWhiteSpace(dsn))
{
loggerConfiguration.WriteTo.Sentry(o =>
{
o.Dsn = dsn;
o.Release = UpdateService.CurrentVersionText;
o.Environment = envIsDev ? "development" : "production";
o.MinimumEventLevel = LogEventLevel.Error; // Error/Fatal become Sentry issues
// Warning+, not Information+: Info-level logs include download/series titles and are
// otherwise attached verbatim as breadcrumbs on every reported issue — that's real user
// activity (and, for private trackers, an account username) leaving the machine on any
// unrelated crash. Warning+ still gives useful context without the activity log.
o.MinimumBreadcrumbLevel = LogEventLevel.Warning;
});
}
});
// Port: honour an explicit --urls/ASPNETCORE_URLS/launchSettings value; otherwise bind our
// default port, walking forward if another app already holds it (5000 is out — macOS AirPlay
// Receiver squats on it). The tray menu's Dashboard item reads the actual bound URL at runtime.
var agentAllowsRemote = ReadAgentApiRemoteAccessEnabled();
if (string.IsNullOrEmpty(builder.Configuration[Microsoft.AspNetCore.Hosting.WebHostDefaults.ServerUrlsKey]))
{
// The desktop UI and the token-bearing agent endpoints never get an automatic plaintext LAN
// listener. Remote access is exposed through a local TLS reverse proxy, or through an explicit
// HTTPS Kestrel URL/certificate supplied by the operator. The auth middleware rejects direct
// non-loopback HTTP even when an explicit wildcard HTTP URL is supplied by mistake.
builder.WebHost.UseUrls($"http://localhost:{FindFreePort(47820)}");
}
// AllowedHosts (appsettings.json) is normally restricted to literal loopback hostnames. A remote
// HTTPS listener/proxy needs its own Host value, but broaden that filter only when both persisted
// remote-access gates were on at startup. Origin validation and the route boundary remain the
// DNS-rebinding defenses in that mode.
if (agentAllowsRemote)
builder.Configuration["AllowedHosts"] = "*";
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddMudServices();
builder.Services.AddSecretProtection();
// Database (SQLite in the per-user data directory; next to the executable on non-macOS)
builder.Services.AddDbContextFactory<AppDbContext>(o => o.UseSqlite($"Data Source={AppPaths.DatabasePath}"));
builder.Services.AddAppHttpClients();
builder.Services.AddTorrentSearch();
// UI localization — languages live in Resources/i18n/*.json.
builder.Services.AddSingleton<LocalizationService>();
builder.Services.AddNotificationChannels();
builder.Services.AddDownloadEngine(builder.Configuration);
builder.Services.AddSelfUpdate();
builder.Services.AddDataServices();
builder.Services.AddAgentApi();
builder.Services.AddOpenApi();
// A TLS reverse proxy running on this machine may preserve the real client address and HTTPS
// scheme. Trust forwarding headers only from literal loopback and only one hop deep; otherwise a
// LAN caller could forge X-Forwarded-For: 127.0.0.1 and receive the tokenless local exemption.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.ForwardLimit = 1;
options.RequireHeaderSymmetry = true;
options.KnownProxies.Clear();
options.KnownProxies.Add(System.Net.IPAddress.Loopback);
options.KnownProxies.Add(System.Net.IPAddress.IPv6Loopback);
});
// Minimal APIs only throw on an unreadable request body in Development; elsewhere they write a
// bare 400 with no body at all. Force the throw so AgentApiErrorMiddleware can render the API's
// JSON error envelope, and so a malformed request behaves identically in dev and in a release
// build rather than losing its explanation exactly where it is hardest to debug.
builder.Services.Configure<RouteHandlerOptions>(o => o.ThrowOnBadRequest = true);
builder.Services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database");
var app = builder.Build();
// Create/upgrade the database schema (via EF Core migrations — see AppDbContext.MigrateAsync for
// the safe path from the old EnsureCreated()-based schema) and the settings row, on first run.
using (var scope = app.Services.CreateScope())
{
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
await using var db = await factory.CreateDbContextAsync();
await db.MigrateAsync();
await db.GetSettingsAsync();
}
// Restore the persisted UI language now that the settings row exists.
await app.Services.GetRequiredService<LocalizationService>().InitializeAsync();
// Generate the remote-access token once, even while the feature is disabled, so enabling it in
// Settings is a single save. Publish it together with Kestrel's resolved URL once binding is done.
var agentAccess = app.Services.GetRequiredService<AgentAccess>();
var agentToken = await agentAccess.EnsureTokenAsync();
var agentEndpoint = app.Services.GetRequiredService<AgentEndpointInfo>();
if (!app.Environment.IsEnvironment("Testing"))
app.Lifetime.ApplicationStarted.Register(() =>
{
try
{
agentEndpoint.Publish(DashboardUrl(app), agentToken);
}
catch (Exception ex)
{
Log.Error(ex, "Could not write agent endpoint discovery file {Path}", AppPaths.AgentEndpointPath);
}
});
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
}
app.UseForwardedHeaders();
// Safe to set unconditionally: it's a strictly local desktop app, but these cost nothing and mean
// there's a baseline of defense if this Kestrel instance is ever fronted by a proxy or otherwise
// made reachable beyond localhost.
app.Use(async (context, next) =>
{
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("Referrer-Policy", "same-origin");
await next();
});
app.UseMiddleware<AgentApiAuthMiddleware>();
// Inside the auth boundary, so a rejected caller never reaches it: renders a malformed agent
// request body as the API's own JSON error envelope instead of an exception page.
app.UseMiddleware<AgentApiErrorMiddleware>();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapHealthChecks("/health");
app.MapOpenApi();
app.MapAgentApi();
app.MapMcp("/mcp");
// On macOS and Windows, run as a tray/menu-bar agent: start Kestrel on background threads and
// hand the main thread to the native event loop the tray icon needs (AppKit's run loop on macOS,
// a Win32 message loop on Windows). Set MD_NO_TRAY=1 to run headless instead (used by the
// dev/preview profile). Any other OS just runs the web host normally.
if (builder.Configuration["MD_NO_TRAY"] != "1" && (OperatingSystem.IsMacOS() || OperatingSystem.IsWindows()))
{
// Block (stay on the main thread) rather than await, so the native run loop gets thread 0.
app.StartAsync().GetAwaiter().GetResult();
// SIGTERM/Ctrl-C only *signal* shutdown — normally app.Run() notices and stops the host, but
// here the main thread is parked in the tray's event loop, which would leave a zombie process
// whose host never stops. Watch for the signal on a background thread, run the graceful
// shutdown, then exit the process.
_ = Task.Run(async () =>
{
await app.WaitForShutdownAsync();
Environment.Exit(0);
});
var dashboardUrl = DashboardUrl(app);
var downloadManager = app.Services.GetRequiredService<DownloadManager>();
var updateService = app.Services.GetRequiredService<UpdateService>();
if (OperatingSystem.IsMacOS())
MacTrayApp.Run(app, dashboardUrl, downloadManager, updateService);
else
WindowsTrayApp.Run(app, dashboardUrl, downloadManager, updateService);
}
else
{
app.Run();
}
}
static string DashboardUrl(WebApplication app)
{
var url = app.Urls.FirstOrDefault() ?? "http://localhost:47820";
// A wildcard/any-address bind isn't browsable; point the menu item at localhost.
return url.Replace("0.0.0.0", "localhost").Replace("[::]", "localhost").Replace("//+:", "//localhost:");
}
// Returns the preferred port if free, else the first free port after it. Binding (rather than
// connecting) is the reliable probe: a port can be taken without anything accepting connections.
static int FindFreePort(int preferred)
{
for (var port = preferred; port < preferred + 50; port++)
{
try
{
var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, port);
listener.Start();
listener.Stop();
return port;
}
catch (System.Net.Sockets.SocketException)
{
// taken — try the next one
}
}
return 0; // let the OS pick; Kestrel resolves the real port before app.Urls is read
}
// Host filtering is configured before DI and EF are available. Read both non-secret gates from
// SQLite and fail closed when the database/columns do not exist yet. Merely leaving AllowRemote
// checked while Agent access is off must never broaden the accepted Host header set.
static bool ReadAgentApiRemoteAccessEnabled()
{
if (!File.Exists(AppPaths.DatabasePath))
return false;
try
{
using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={AppPaths.DatabasePath};Mode=ReadOnly");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('Settings') WHERE name IN ('AgentApiEnabled', 'AgentApiAllowRemote')";
if (Convert.ToInt32(command.ExecuteScalar()) != 2)
return false;
command.CommandText = "SELECT COALESCE(\"AgentApiEnabled\", 0) * COALESCE(\"AgentApiAllowRemote\", 0) FROM \"Settings\" WHERE \"Id\" = 1";
return Convert.ToInt32(command.ExecuteScalar()) != 0;
}
catch (Exception ex)
{
Log.Warning(ex, "Could not read Agent API remote-access state at startup; accepting loopback hosts only");
return false;
}
}
// Exposed for WebApplicationFactory-based endpoint tests.
public partial class Program;