Repository navigation
Expand file tree
/
Copy pathProgram.cs
More file actions
320 lines (294 loc) · 15.8 KB
/
Copy pathProgram.cs
File metadata and controls
320 lines (294 loc) · 15.8 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
using System.Globalization;
using System.Text;
using FileAccessMonitor.Data;
using FileAccessMonitor.Models;
using FileAccessMonitor.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Localization;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Serilog;
var logDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"FileAccessMonitor", "logs");
Directory.CreateDirectory(logDir);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
// Framework logs (EF Core SQL, per-request ASP.NET Core) are too chatty for a service log
.MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", Serilog.Events.LogEventLevel.Information)
.WriteTo.Console()
.WriteTo.File(
path: Path.Combine(logDir, "app-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog();
var userSettingsFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"FileAccessMonitor", "settings.json");
builder.Configuration.AddJsonFile(userSettingsFile, optional: true, reloadOnChange: true);
builder.Host.UseWindowsService(options =>
{
options.ServiceName = "FileAccessMonitor";
});
var dbPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"FileAccessMonitor", "fileaccess.db");
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
builder.Services.AddDbContext<AccessDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}"));
// Identity
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
})
.AddEntityFrameworkStores<AccessDbContext>()
.AddErrorDescriber<LocalizedIdentityErrorDescriber>()
.AddDefaultTokenProviders();
// JWT: use the configured key only if it is a real secret; otherwise generate
// one per installation so the published sample key can never be used to forge tokens.
var jwtKey = builder.Configuration["Jwt:Key"];
if (string.IsNullOrWhiteSpace(jwtKey)
|| jwtKey.StartsWith("CHANGE_THIS", StringComparison.OrdinalIgnoreCase)
|| Encoding.UTF8.GetByteCount(jwtKey) < 32)
{
if (!string.IsNullOrWhiteSpace(jwtKey))
Log.Warning("Jwt:Key is a placeholder or shorter than 32 bytes; using a generated key instead.");
jwtKey = SecretStore.GetOrCreateJwtKey();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Jwt:Key"] = jwtKey });
}
var jwtIssuer = builder.Configuration["Jwt:Issuer"]!;
var jwtAudience = builder.Configuration["Jwt:Audience"]!;
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
};
// Reject tokens of deleted users, or issued before a password/role change
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var userManager = context.HttpContext.RequestServices.GetRequiredService<UserManager<ApplicationUser>>();
if (!await TokenService.IsCurrentAsync(context.Principal!, userManager))
context.Fail("Token has been revoked.");
}
};
});
// Encrypts alert secrets (webhook URL, SMTP password) stored in settings.json
builder.Services.AddDataProtection()
.SetApplicationName("FileAccessMonitor")
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "FileAccessMonitor", "keys")))
.ProtectKeysWithDpapi(protectToLocalMachine: true);
// Behind a reverse proxy, take the client IP (for the operation log) from X-Forwarded-For,
// but only from the proxies listed in ReverseProxy:KnownProxies.
var knownProxies = builder.Configuration.GetSection("ReverseProxy:KnownProxies").Get<string[]>() ?? [];
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor;
foreach (var proxy in knownProxies)
if (System.Net.IPAddress.TryParse(proxy, out var ip)) options.KnownProxies.Add(ip);
});
builder.Services.AddAuthorization();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<SaclManagerService>();
builder.Services.AddSingleton<DnsResolverService>();
builder.Services.AddSingleton<AuditLog>();
builder.Services.AddSingleton<AlertService>();
builder.Services.AddScoped<TokenService>();
builder.Services.AddScoped<UserScopeService>();
builder.Services.AddSingleton<IRecordSink, LocalRecordSink>();
builder.Services.AddHostedService<EventLogCollectorService>();
builder.Services.AddSingleton<RetentionService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<RetentionService>());
builder.Services.AddControllers();
var app = builder.Build();
// DB migration + seed initial admin
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AccessDbContext>();
db.Database.EnsureCreated(); // new DB: creates all tables
// Existing DB upgrade: create Identity tables if missing
await db.Database.ExecuteSqlRawAsync(@"
CREATE TABLE IF NOT EXISTS ""AspNetRoles"" (
""Id"" TEXT NOT NULL CONSTRAINT ""PK_AspNetRoles"" PRIMARY KEY,
""Name"" TEXT NULL, ""NormalizedName"" TEXT NULL, ""ConcurrencyStamp"" TEXT NULL)");
await db.Database.ExecuteSqlRawAsync(@"
CREATE TABLE IF NOT EXISTS ""AspNetUsers"" (
""Id"" TEXT NOT NULL CONSTRAINT ""PK_AspNetUsers"" PRIMARY KEY,
""DisplayName"" TEXT NOT NULL DEFAULT '',
""UserName"" TEXT NULL, ""NormalizedUserName"" TEXT NULL,
""Email"" TEXT NULL, ""NormalizedEmail"" TEXT NULL,
""EmailConfirmed"" INTEGER NOT NULL, ""PasswordHash"" TEXT NULL,
""SecurityStamp"" TEXT NULL, ""ConcurrencyStamp"" TEXT NULL,
""PhoneNumber"" TEXT NULL, ""PhoneNumberConfirmed"" INTEGER NOT NULL,
""TwoFactorEnabled"" INTEGER NOT NULL, ""LockoutEnd"" TEXT NULL,
""LockoutEnabled"" INTEGER NOT NULL, ""AccessFailedCount"" INTEGER NOT NULL)");
await db.Database.ExecuteSqlRawAsync(@"
CREATE TABLE IF NOT EXISTS ""AspNetRoleClaims"" (
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_AspNetRoleClaims"" PRIMARY KEY AUTOINCREMENT,
""RoleId"" TEXT NOT NULL, ""ClaimType"" TEXT NULL, ""ClaimValue"" TEXT NULL,
CONSTRAINT ""FK_AspNetRoleClaims_AspNetRoles_RoleId"" FOREIGN KEY (""RoleId"") REFERENCES ""AspNetRoles"" (""Id"") ON DELETE CASCADE)");
await db.Database.ExecuteSqlRawAsync(@"
CREATE TABLE IF NOT EXISTS ""AspNetUserClaims"" (
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_AspNetUserClaims"" PRIMARY KEY AUTOINCREMENT,
""UserId"" TEXT NOT NULL, ""ClaimType"" TEXT NULL, ""ClaimValue"" TEXT NULL,
CONSTRAINT ""FK_AspNetUserClaims_AspNetUsers_UserId"" FOREIGN KEY (""UserId"") REFERENCES ""AspNetUsers"" (""Id"") ON DELETE CASCADE)");
await db.Database.ExecuteSqlRawAsync(@"
CREATE TABLE IF NOT EXISTS ""AspNetUserLogins"" (
""LoginProvider"" TEXT NOT NULL, ""ProviderKey"" TEXT NOT NULL,
""ProviderDisplayName"" TEXT NULL, ""UserId"" TEXT NOT NULL,
CONSTRAINT ""PK_AspNetUserLogins"" PRIMARY KEY (""LoginProvider"", ""ProviderKey""),
CONSTRAINT ""FK_AspNetUserLogins_AspNetUsers_UserId"" FOREIGN KEY (""UserId"") REFERENCES ""AspNetUsers"" (""Id"") ON DELETE CASCADE)");
await db.Database.ExecuteSqlRawAsync(@"
CREATE TABLE IF NOT EXISTS ""AspNetUserRoles"" (
""UserId"" TEXT NOT NULL, ""RoleId"" TEXT NOT NULL,
CONSTRAINT ""PK_AspNetUserRoles"" PRIMARY KEY (""UserId"", ""RoleId""),
CONSTRAINT ""FK_AspNetUserRoles_AspNetRoles_RoleId"" FOREIGN KEY (""RoleId"") REFERENCES ""AspNetRoles"" (""Id"") ON DELETE CASCADE,
CONSTRAINT ""FK_AspNetUserRoles_AspNetUsers_UserId"" FOREIGN KEY (""UserId"") REFERENCES ""AspNetUsers"" (""Id"") ON DELETE CASCADE)");
await db.Database.ExecuteSqlRawAsync(@"
CREATE TABLE IF NOT EXISTS ""AspNetUserTokens"" (
""UserId"" TEXT NOT NULL, ""LoginProvider"" TEXT NOT NULL, ""Name"" TEXT NOT NULL, ""Value"" TEXT NULL,
CONSTRAINT ""PK_AspNetUserTokens"" PRIMARY KEY (""UserId"", ""LoginProvider"", ""Name""),
CONSTRAINT ""FK_AspNetUserTokens_AspNetUsers_UserId"" FOREIGN KEY (""UserId"") REFERENCES ""AspNetUsers"" (""Id"") ON DELETE CASCADE)");
await db.Database.ExecuteSqlRawAsync(@"CREATE UNIQUE INDEX IF NOT EXISTS ""RoleNameIndex"" ON ""AspNetRoles"" (""NormalizedName"")");
await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""IX_AspNetRoleClaims_RoleId"" ON ""AspNetRoleClaims"" (""RoleId"")");
await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""EmailIndex"" ON ""AspNetUsers"" (""NormalizedEmail"")");
await db.Database.ExecuteSqlRawAsync(@"CREATE UNIQUE INDEX IF NOT EXISTS ""UserNameIndex"" ON ""AspNetUsers"" (""NormalizedUserName"")");
await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""IX_AspNetUserClaims_UserId"" ON ""AspNetUserClaims"" (""UserId"")");
await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""IX_AspNetUserLogins_UserId"" ON ""AspNetUserLogins"" (""UserId"")");
await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""IX_AspNetUserRoles_RoleId"" ON ""AspNetUserRoles"" (""RoleId"")");
// v2: add client connection info columns (only if missing, so no failed SQL is logged)
var accessColumns = await db.Database
.SqlQueryRaw<string>(@"SELECT ""name"" AS ""Value"" FROM pragma_table_info('AccessRecords')")
.ToListAsync();
var addColumnSql = new Dictionary<string, string>
{
["ClientIp"] = @"ALTER TABLE ""AccessRecords"" ADD COLUMN ""ClientIp"" TEXT NULL",
["ClientMachine"] = @"ALTER TABLE ""AccessRecords"" ADD COLUMN ""ClientMachine"" TEXT NULL",
["IsFolder"] = @"ALTER TABLE ""AccessRecords"" ADD COLUMN ""IsFolder"" INTEGER NOT NULL DEFAULT 0",
["ServerName"] = @"ALTER TABLE ""AccessRecords"" ADD COLUMN ""ServerName"" TEXT NOT NULL DEFAULT ''",
};
foreach (var (column, sql) in addColumnSql)
{
if (!accessColumns.Contains(column, StringComparer.OrdinalIgnoreCase))
await db.Database.ExecuteSqlRawAsync(sql);
}
// v4: records made before folders were told apart: a path that other records have as
// their folder is a folder (deleted or renamed folders used to be recorded as files)
if (!accessColumns.Contains("IsFolder", StringComparer.OrdinalIgnoreCase))
await db.Database.ExecuteSqlRawAsync(@"
UPDATE ""AccessRecords"" SET ""IsFolder"" = 1
WHERE ""FilePath"" IN (SELECT DISTINCT ""FolderPath"" FROM ""AccessRecords"")");
// v6: records made before agents existed all come from this machine
if (!accessColumns.Contains("ServerName", StringComparer.OrdinalIgnoreCase))
await db.Database.ExecuteSqlRawAsync(
@"UPDATE ""AccessRecords"" SET ""ServerName"" = {0} WHERE ""ServerName"" = ''", Environment.MachineName);
await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""IX_AccessRecords_ServerName"" ON ""AccessRecords"" (""ServerName"")");
// v3: operation log and collector position
await db.Database.ExecuteSqlRawAsync(@"
CREATE TABLE IF NOT EXISTS ""AuditLogs"" (
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_AuditLogs"" PRIMARY KEY AUTOINCREMENT,
""Time"" TEXT NOT NULL, ""UserName"" TEXT NOT NULL, ""Action"" TEXT NOT NULL,
""Detail"" TEXT NULL, ""ClientIp"" TEXT NULL)");
await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""IX_AuditLogs_Time"" ON ""AuditLogs"" (""Time"")");
await db.Database.ExecuteSqlRawAsync(@"
CREATE TABLE IF NOT EXISTS ""CollectorStates"" (
""Key"" TEXT NOT NULL CONSTRAINT ""PK_CollectorStates"" PRIMARY KEY,
""Value"" TEXT NOT NULL)");
// v5: per-user folder scopes
await db.Database.ExecuteSqlRawAsync(@"
CREATE TABLE IF NOT EXISTS ""UserScopes"" (
""UserId"" TEXT NOT NULL CONSTRAINT ""PK_UserScopes"" PRIMARY KEY,
""Paths"" TEXT NOT NULL, ""NotifyEmail"" TEXT NULL)");
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
foreach (var role in new[] { "Admin", "Viewer" })
{
if (!await roleManager.RoleExistsAsync(role))
await roleManager.CreateAsync(new IdentityRole(role));
}
// Create default admin if no users exist
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
if (!userManager.Users.Any())
{
var admin = new ApplicationUser
{
UserName = "admin",
DisplayName = "Administrator",
Email = "admin@localhost"
};
var password = SecretStore.CreateInitialAdminPassword();
var result = await userManager.CreateAsync(admin, password);
if (result.Succeeded)
{
await userManager.AddToRoleAsync(admin, "Admin");
Log.Information("Default admin user created. Username: admin / Password is in {File} (readable by Administrators only).",
SecretStore.InitialAdminPasswordFile);
}
else
{
SecretStore.DeleteInitialAdminPassword();
Log.Error("Failed to create default admin: {Errors}",
string.Join(" ", result.Errors.Select(e => e.Description)));
}
}
}
// Load DNS server setting
var dnsResolver = app.Services.GetRequiredService<DnsResolverService>();
dnsResolver.SetDnsServer(app.Configuration["DnsServer"]);
// Security headers. All assets are served locally, so the CSP only allows 'self', plus the GitHub
// API, which an Admin's browser asks for the latest release (update check, can be turned off).
// 'unsafe-inline' is still needed for the inline onclick handlers / style attributes in the UI.
app.Use(async (context, next) =>
{
var headers = context.Response.Headers;
headers["Content-Security-Policy"] =
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.github.com; object-src 'none'; " +
"base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
headers["X-Content-Type-Options"] = "nosniff";
headers["X-Frame-Options"] = "DENY";
headers["Referrer-Policy"] = "no-referrer";
await next();
});
// API messages follow the GUI language (Accept-Language). Formatting stays culture-invariant.
var uiCultures = new[] { new CultureInfo("en"), new CultureInfo("ja") };
app.UseRequestLocalization(new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture(CultureInfo.InvariantCulture, uiCultures[0]),
SupportedCultures = [CultureInfo.InvariantCulture],
SupportedUICultures = uiCultures,
});
if (knownProxies.Length > 0) app.UseForwardedHeaders();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();