Everything on, phase 1: geolocation fetches itself, the deploy signs in as @thepra, the crawler is on, sign-up by invitation
Owner decisions (2026-10-04, recorded in docs/ROADMAP.md): production runs everything that is built, and nothing waits
on a person running a command.
- Geolocation updates itself. GeoUpdater, a hosted service, checks daily whether each DB-IP Lite database was built this
month. If not, it fetches this month's, or last month's early in the month. It installs a file only once it opens as
the right kind of database, then swaps it in atomically, and the locator reloads at once. Lookups now run under the
lock, so a reload can no longer dispose a reader mid-lookup. The systemd timer, its script and their setup.sh lines
are gone: the root step they needed never happened, and none is needed now. /stargazing names the database in use.
- The admin CLI runs after the app is built, with every service and nothing started.
- `create-root <login> [--admin]` takes the password on stdin; it is how the first login is made while sign-up is
closed.
- `smoke <persona>` keeps the root `deploy-smoke` and an undiscoverable persona, and gives the root a new password
on every run.
- The deploy signs in as @thepra. It runs the CLI, gets a token through the real OAuth flow (tools/smoke/oauth.sh,
moved out of the pasture's privapub_token, which now uses it), checks the signed-in API and that @thepra is
undiscoverable, then revokes the token. PRIVAPUB_SMOKE_TOKEN is gone.
- The deploy also fails when:
- NodeInfo and the instance API disagree about registrations;
- /stargazing does not say the crawler is on;
- the geolocation databases are missing or more than 40 days old.
- The crawler is on in production, seeded with ten large servers of different kinds. FEDERATION.md now describes it
and how to opt out.
- One registrations switch (Registrations:Mode, default Invitations; Open in tests and the pasture). It is read by
open sign-up (403 when closed), NodeInfo `openRegistrations`, and v1 and v2 of the instance API, so they can no longer
disagree. Before, NodeInfo said open and the instance API said closed. Group invitations always work, so
invites_enabled is true.
- A persona edit through /clientapi no longer resets what the Mastodon API set (discoverable, locked, quote policy…):
the theme is merged into the settings instead of replacing them.
650 tests pass. The deploy's smoke step was rehearsed against the pasture's PrivaPub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
1 parent
cd5eb25948
commit
5f56681c01
33 files changed
+909
-157
No files matched your search
@@ -25,8 +25,8 @@ namespace PrivaPub.Infrastructure.Geo
|
||||
|
||||
public sealed class DbIpLocator : IGeoLocator, IDisposable
|
||||
{
|
||||
public const string CityFile = "dbip-city-lite.mmdb";
|
||||
public const string AsnFile = "dbip-asn-lite.mmdb";
|
||||
public static readonly string CityFile = GeoUpdater.FileOf("city");
|
||||
public static readonly string AsnFile = GeoUpdater.FileOf("asn");
|
||||
static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(10);
|
||||
|
||||
readonly IOptionsMonitor<StatisticsOptions> _options;
|
||||
@@ -60,18 +60,17 @@ namespace PrivaPub.Infrastructure.Geo
|
||||
if (address == default || !IpRangeGuard.IsPublic(address))
|
||||
return default;
|
||||
Refresh();
|
||||
Reader city, asn;
|
||||
lock (_lock)
|
||||
{
|
||||
city = _city;
|
||||
asn = _asn;
|
||||
}
|
||||
if (city == default && asn == default)
|
||||
return default;
|
||||
Dictionary<string, object> place, network;
|
||||
try
|
||||
{
|
||||
var place = city?.Find<Dictionary<string, object>>(address);
|
||||
var network = asn?.Find<Dictionary<string, object>>(address);
|
||||
// under the lock, so a reload cannot dispose a reader in the middle of a lookup; lookups are rare
|
||||
lock (_lock)
|
||||
{
|
||||
if (_city == default && _asn == default)
|
||||
return default;
|
||||
place = _city?.Find<Dictionary<string, object>>(address);
|
||||
network = _asn?.Find<Dictionary<string, object>>(address);
|
||||
}
|
||||
var location = Section(place, "location");
|
||||
return new GeoFix(
|
||||
Text(Section(place, "country"), "iso_code"),
|
||||
@@ -88,6 +87,14 @@ namespace PrivaPub.Infrastructure.Geo
|
||||
}
|
||||
}
|
||||
|
||||
// Looks at the files again now instead of within the next ten minutes: GeoUpdater has just replaced one.
|
||||
public void Reload()
|
||||
{
|
||||
lock (_lock)
|
||||
_checkedAt = DateTime.MinValue;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
void Refresh()
|
||||
{
|
||||
if (DateTime.UtcNow - _checkedAt < CheckInterval)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using MaxMind.Db;
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using PrivaPub.Infrastructure.Statistics;
|
||||
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Geo
|
||||
{
|
||||
// Keeps the DB-IP Lite databases current by itself, so a deploy needs no timer and no root: once a day it checks
|
||||
// whether each database was built this month and, if not, fetches this month's (or, early in the month, last month's),
|
||||
// checks it opens as the right kind of database and swaps it in. This and SMTP are the server's only traffic that is not
|
||||
// federation, which is why it has its own client instead of IFederationHttp.
|
||||
public sealed class GeoUpdater : BackgroundService
|
||||
{
|
||||
public const string ClientName = "geo";
|
||||
static readonly string[] Kinds = { "city", "asn" };
|
||||
static readonly TimeSpan FirstWait = TimeSpan.FromSeconds(30);
|
||||
static readonly TimeSpan Interval = TimeSpan.FromHours(24);
|
||||
|
||||
readonly IHttpClientFactory _http;
|
||||
readonly IOptionsMonitor<StatisticsOptions> _options;
|
||||
readonly IGeoLocator _locator;
|
||||
readonly ILogger<GeoUpdater> _logger;
|
||||
|
||||
public GeoUpdater(IHttpClientFactory http, IOptionsMonitor<StatisticsOptions> options, IGeoLocator locator, ILogger<GeoUpdater> logger)
|
||||
{
|
||||
_http = http;
|
||||
_options = options;
|
||||
_locator = locator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public static string FileOf(string kind) => $"dbip-{kind}-lite.mmdb";
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(FirstWait, stoppingToken);
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_options.CurrentValue.Geo.AutoUpdate)
|
||||
await Update(DateTime.UtcNow, stoppingToken);
|
||||
await Task.Delay(Interval + TimeSpan.FromMinutes(Random.Shared.Next(60)), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
// The number of databases replaced. A failure keeps the database already there and is tried again the next day.
|
||||
public async Task<int> Update(DateTime now, CancellationToken token)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
var replaced = 0;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(options.GeoDirectory);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Cannot create {Directory}; servers are not located", options.GeoDirectory);
|
||||
return 0;
|
||||
}
|
||||
foreach (var kind in Kinds)
|
||||
{
|
||||
var path = Path.Combine(options.GeoDirectory, FileOf(kind));
|
||||
var built = BuiltMonth(path);
|
||||
foreach (var month in new[] { Month(now), Month(now).AddMonths(-1) })
|
||||
{
|
||||
if (built >= month)
|
||||
break;
|
||||
try
|
||||
{
|
||||
if (await Install(kind, month, path, options, token))
|
||||
{
|
||||
replaced++;
|
||||
_logger.LogInformation("Installed DB-IP Lite {Kind} {Month:yyyy-MM}", kind, month);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidDatabaseException or InvalidDataException
|
||||
or TaskCanceledException && !token.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not install DB-IP Lite {Kind} {Month:yyyy-MM}", kind, month);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (replaced > 0 && _locator is DbIpLocator locator)
|
||||
locator.Reload();
|
||||
return replaced;
|
||||
}
|
||||
|
||||
static DateTime Month(DateTime at) => new(at.Year, at.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
static DateTime BuiltMonth(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return DateTime.MinValue;
|
||||
try
|
||||
{
|
||||
using var reader = new Reader(path, FileAccessMode.MemoryMapped);
|
||||
return Month(reader.Metadata.BuildDate);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDatabaseException or IOException)
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
}
|
||||
|
||||
// False when that month is not published (yet); throws when it is published but cannot be used.
|
||||
async Task<bool> Install(string kind, DateTime month, string path, StatisticsOptions options, CancellationToken token)
|
||||
{
|
||||
var url = $"{options.Geo.DownloadBase.TrimEnd('/')}/dbip-{kind}-lite-{month:yyyy-MM}.mmdb.gz";
|
||||
var packed = path + ".gz.part";
|
||||
var unpacked = path + ".new";
|
||||
try
|
||||
{
|
||||
using (var response = await _http.CreateClient(ClientName).GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token))
|
||||
{
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return false;
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using var source = await response.Content.ReadAsStreamAsync(token);
|
||||
await using var target = File.Create(packed);
|
||||
await CopyCapped(source, target, options.Geo.MaxDownloadBytes, token);
|
||||
}
|
||||
await using (var source = new GZipStream(File.OpenRead(packed), CompressionMode.Decompress))
|
||||
await using (var target = File.Create(unpacked))
|
||||
await CopyCapped(source, target, options.Geo.MaxDatabaseBytes, token);
|
||||
using (var reader = new Reader(unpacked, FileAccessMode.MemoryMapped))
|
||||
if (!reader.Metadata.DatabaseType.Contains(kind, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDatabaseException($"{url} is a {reader.Metadata.DatabaseType} database, not {kind}");
|
||||
File.Move(unpacked, path, overwrite: true);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(packed);
|
||||
File.Delete(unpacked);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task CopyCapped(Stream source, Stream target, long limit, CancellationToken token)
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = await source.ReadAsync(buffer, token)) > 0)
|
||||
{
|
||||
total += read;
|
||||
if (total > limit)
|
||||
throw new InvalidDataException($"more than {limit} bytes");
|
||||
await target.WriteAsync(buffer.AsMemory(0, read), token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user