release(v1.7.21-alpha): operator-editable FIPS seed anchors

Adds a local seed-anchor list at <data_dir>/seed-anchors.json. Each
entry is {npub, address, transport, label}. On archipelago startup
and every 5 minutes the list is pushed into the running fips daemon
via `fipsctl connect <npub> <addr> <transport>`, so a cluster can
anchor itself independently of the global fips.v0l.io. A flaky or
unreachable public anchor no longer strands a fresh install.

New RPCs:
- fips.list-seed-anchors
- fips.add-seed-anchor (validates npub1… + host:port)
- fips.remove-seed-anchor
- fips.apply-seed-anchors (on-demand re-dial)

New standalone UI card at views/server/FipsSeedAnchorsCard.vue. Not
wired into Home.vue / Server.vue — operator places it per the
entry-point convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-04-21 06:21:37 -04:00
parent 4d8a9e66e3
commit e88719df50
12 changed files with 540 additions and 16 deletions

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.20-alpha"
version = "1.7.21-alpha"
dependencies = [
"anyhow",
"archipelago-container",

View File

@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.20-alpha"
version = "1.7.21-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]

View File

@ -414,6 +414,16 @@ impl RpcHandler {
"fips.install" => self.handle_fips_install().await,
"fips.restart" => self.handle_fips_restart().await,
"fips.reconnect" => self.handle_fips_reconnect().await,
"fips.list-seed-anchors" => self.handle_fips_list_seed_anchors().await,
"fips.add-seed-anchor" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_fips_add_seed_anchor(&p).await
}
"fips.remove-seed-anchor" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_fips_remove_seed_anchor(&p).await
}
"fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await,
// System updates
"update.check" => self.handle_update_check().await,

View File

@ -129,4 +129,66 @@ impl RpcHandler {
"after": after,
}))
}
/// List the seed-anchor entries configured on this node.
pub(super) async fn handle_fips_list_seed_anchors(&self) -> Result<serde_json::Value> {
let list = fips::anchors::load(&self.config.data_dir).await?;
Ok(serde_json::json!({ "seed_anchors": list }))
}
/// Add (or update) a seed anchor and immediately push it into the
/// running daemon. Params: `{ npub, address, transport?, label? }`.
pub(super) async fn handle_fips_add_seed_anchor(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let anchor: fips::anchors::SeedAnchor = serde_json::from_value(params.clone())
.map_err(|e| anyhow::anyhow!("bad seed anchor payload: {}", e))?;
if !anchor.npub.starts_with("npub1") {
anyhow::bail!("npub must be bech32 (npub1...)");
}
if !anchor.address.contains(':') {
anyhow::bail!("address must be host:port (e.g. 192.168.1.116:8668)");
}
let list =
fips::anchors::add(&self.config.data_dir, anchor.clone()).await?;
// Push just the newly-added anchor into the running daemon so
// the user sees effect without waiting for the periodic apply.
let results = fips::anchors::apply(&[anchor]).await;
Ok(serde_json::json!({
"seed_anchors": list,
"apply": results.iter().map(|r| {
serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message })
}).collect::<Vec<_>>(),
}))
}
/// Remove a seed anchor by npub. Params: `{ npub }`. Does NOT tear
/// down an already-authenticated peer connection — it only stops
/// us from re-dialing the anchor on the next apply cycle.
pub(super) async fn handle_fips_remove_seed_anchor(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let npub = params
.get("npub")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing npub"))?;
let list = fips::anchors::remove(&self.config.data_dir, npub).await?;
Ok(serde_json::json!({ "seed_anchors": list }))
}
/// Re-apply all seed anchors to the running daemon. Useful after a
/// FIPS restart or when the user wants to force a reconnection
/// attempt without waiting for the periodic apply loop.
pub(super) async fn handle_fips_apply_seed_anchors(&self) -> Result<serde_json::Value> {
let list = fips::anchors::load(&self.config.data_dir).await?;
let results = fips::anchors::apply(&list).await;
Ok(serde_json::json!({
"applied": results.len(),
"results": results.iter().map(|r| {
serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message })
}).collect::<Vec<_>>(),
}))
}
}

View File

@ -0,0 +1,241 @@
//! Seed-anchor management for FIPS bootstrap.
//!
//! A freshly-installed node can't reach the global mesh via npub
//! routing until it's connected to at least one peer that's already in
//! the DHT. Upstream `fips` solves this by dialing a public anchor
//! (e.g. `fips.v0l.io`) on first start. That's a single point of
//! failure and doesn't help nodes behind restrictive firewalls or
//! intermittent networks — archipelago operators reported fresh
//! installs failing to reach any public anchor.
//!
//! This module adds a local, operator-editable seed-anchor list. Each
//! entry is a `{npub, address, transport}` triple that archipelago
//! pushes into the running daemon via `fipsctl connect` on startup and
//! periodically thereafter. If one anchor falls over, the next one
//! seeds the DHT instead. A well-configured cluster (e.g. a VPS
//! running fips in anchor mode + a couple of home nodes) stops
//! depending on the global anchor entirely.
//!
//! The list is persisted at `<data_dir>/seed-anchors.json`. The
//! archipelago service user owns that directory, so no sudo is needed
//! to read or write it.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::process::Command;
/// On-disk filename under `data_dir/`.
const SEED_ANCHORS_FILE: &str = "seed-anchors.json";
/// Public anchor (`fips.v0l.io`) carried as a default seed for fresh
/// installs — the one the upstream daemon dials anyway. Operators can
/// remove it from the UI once their own cluster has independent anchors.
pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "fips.v0l.io:8668";
/// One seed-anchor entry. `address` must be directly dialable (IP or
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
/// "tor", "ethernet" (the values upstream `fipsctl connect` accepts).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SeedAnchor {
/// Bech32 `npub1...` of the anchor's FIPS identity.
pub npub: String,
/// Directly-dialable transport address, e.g. `192.168.1.116:8668`.
pub address: String,
/// Transport to use — almost always `"udp"`.
#[serde(default = "default_transport")]
pub transport: String,
/// Human-readable note shown in the UI (e.g. "Home anchor", "VPS").
#[serde(default)]
pub label: String,
}
fn default_transport() -> String {
"udp".to_string()
}
fn anchors_path(data_dir: &Path) -> PathBuf {
data_dir.join(SEED_ANCHORS_FILE)
}
/// Load the seed-anchor list. Returns an empty list if the file
/// doesn't exist yet — a first-boot node with no operator config.
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
let path = anchors_path(data_dir);
if !path.exists() {
return Ok(Vec::new());
}
let bytes = tokio::fs::read(&path)
.await
.with_context(|| format!("read {}", path.display()))?;
let anchors: Vec<SeedAnchor> = serde_json::from_slice(&bytes)
.with_context(|| format!("parse {}", path.display()))?;
Ok(anchors)
}
/// Persist the list. Overwrites atomically via write-then-rename so a
/// crashed archipelago never leaves a half-written config.
pub async fn save(data_dir: &Path, anchors: &[SeedAnchor]) -> Result<()> {
tokio::fs::create_dir_all(data_dir)
.await
.with_context(|| format!("mkdir -p {}", data_dir.display()))?;
let path = anchors_path(data_dir);
let tmp = path.with_extension("json.tmp");
let json = serde_json::to_vec_pretty(anchors).context("serialize seed anchors")?;
tokio::fs::write(&tmp, json)
.await
.with_context(|| format!("write {}", tmp.display()))?;
tokio::fs::rename(&tmp, &path)
.await
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
/// Add (or update) one anchor, keyed by npub. Returns the resulting list.
pub async fn add(data_dir: &Path, anchor: SeedAnchor) -> Result<Vec<SeedAnchor>> {
let mut list = load(data_dir).await?;
if let Some(existing) = list.iter_mut().find(|a| a.npub == anchor.npub) {
*existing = anchor;
} else {
list.push(anchor);
}
save(data_dir, &list).await?;
Ok(list)
}
/// Remove an anchor by npub. Returns the resulting list.
pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
let mut list = load(data_dir).await?;
list.retain(|a| a.npub != npub);
save(data_dir, &list).await?;
Ok(list)
}
/// Apply the seed anchors to the running FIPS daemon. For each entry,
/// asks `fipsctl connect` to dial the peer. Errors are logged but don't
/// fail the whole operation — a single unreachable anchor shouldn't
/// block the others.
///
/// `fipsctl connect` is idempotent-ish: calling it for an already-
/// connected peer is a no-op at the protocol layer, so re-applying on
/// a timer is safe. Returns a list of per-anchor results for logging.
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
let mut results = Vec::with_capacity(anchors.len());
for anchor in anchors {
let out = Command::new("fipsctl")
.args([
"connect",
&anchor.npub,
&anchor.address,
&anchor.transport,
])
.output()
.await;
let result = match out {
Ok(o) if o.status.success() => ApplyResult {
npub: anchor.npub.clone(),
ok: true,
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
},
Ok(o) => ApplyResult {
npub: anchor.npub.clone(),
ok: false,
message: format!(
"fipsctl exited {}: {}",
o.status,
String::from_utf8_lossy(&o.stderr).trim()
),
},
Err(e) => ApplyResult {
npub: anchor.npub.clone(),
ok: false,
message: format!("fipsctl launch failed: {}", e),
},
};
if result.ok {
tracing::debug!(npub = %result.npub, "Seed anchor applied");
} else {
tracing::warn!(
npub = %result.npub,
message = %result.message,
"Seed anchor apply failed (non-fatal)"
);
}
results.push(result);
}
results
}
/// Outcome of a single `fipsctl connect` call.
#[derive(Debug, Clone)]
pub struct ApplyResult {
pub npub: String,
pub ok: bool,
pub message: String,
}
#[cfg(test)]
mod tests {
use super::*;
fn mk(npub: &str) -> SeedAnchor {
SeedAnchor {
npub: npub.to_string(),
address: "example.test:8668".to_string(),
transport: "udp".to_string(),
label: "test".to_string(),
}
}
#[tokio::test]
async fn load_missing_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let got = load(dir.path()).await.unwrap();
assert!(got.is_empty());
}
#[tokio::test]
async fn save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let a = mk("npub1aaa");
let b = mk("npub1bbb");
save(dir.path(), &[a.clone(), b.clone()]).await.unwrap();
let got = load(dir.path()).await.unwrap();
assert_eq!(got, vec![a, b]);
}
#[tokio::test]
async fn add_replaces_existing_by_npub() {
let dir = tempfile::tempdir().unwrap();
let mut a = mk("npub1aaa");
save(dir.path(), &[a.clone()]).await.unwrap();
a.address = "newhost:8668".to_string();
let list = add(dir.path(), a.clone()).await.unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].address, "newhost:8668");
}
#[tokio::test]
async fn remove_by_npub() {
let dir = tempfile::tempdir().unwrap();
save(
dir.path(),
&[mk("npub1aaa"), mk("npub1bbb"), mk("npub1ccc")],
)
.await
.unwrap();
let list = remove(dir.path(), "npub1bbb").await.unwrap();
assert_eq!(list.len(), 2);
assert!(list.iter().all(|a| a.npub != "npub1bbb"));
}
#[test]
fn seed_anchor_uses_udp_by_default() {
let json = r#"{"npub":"npub1x","address":"h:8668"}"#;
let a: SeedAnchor = serde_json::from_str(json).unwrap();
assert_eq!(a.transport, "udp");
assert_eq!(a.label, "");
}
}

View File

@ -25,6 +25,7 @@
// the module is deliberately API-ready ahead of those call-sites.
#![allow(dead_code)]
pub mod anchors;
pub mod config;
pub mod dial;
pub mod iface;

View File

@ -353,6 +353,34 @@ impl Server {
});
}
// FIPS seed-anchor apply loop — every 5 minutes we re-push the
// configured seed anchors into the running fips daemon via
// `fipsctl connect`. This keeps the mesh bootstrap resilient:
// operators add cluster-local anchors in the UI, and a daemon
// restart or a flaky public anchor can't strand the node.
// First run is delayed 30s so fips has time to come up after
// onboarding before we start dialing.
{
let data_dir = config.data_dir.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(30)).await;
let mut interval = tokio::time::interval(Duration::from_secs(300));
loop {
interval.tick().await;
match crate::fips::anchors::load(&data_dir).await {
Ok(list) if !list.is_empty() => {
let _ = crate::fips::anchors::apply(&list).await;
}
Ok(_) => { /* no seed anchors configured yet */ }
Err(e) => tracing::debug!(
"Seed-anchor apply: load failed (non-fatal): {}",
e
),
}
}
});
}
// did:dht auto-refresh — re-publish DHT records every 2 hours
if config.nostr_discovery_enabled {
let data_dir = config.data_dir.clone();

View File

@ -0,0 +1,169 @@
<template>
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04.054-.09A13.916 13.916 0 0 0 8 11a4 4 0 1 1 8 0c0 1.017-.07 2.019-.203 3M9.497 10.997 14 18m-9.41-3.41L4 18.5" />
</svg>
</div>
<div class="flex-1">
<div class="flex items-start justify-between gap-4 mb-2">
<h2 class="text-xl font-semibold text-white">FIPS Seed Anchors</h2>
<button
type="button"
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors disabled:opacity-60"
:disabled="applying"
:title="applying ? 'Applying…' : 'Re-dial every anchor in the list'"
@click="applyAll"
>
{{ applying ? 'Applying…' : 'Apply now' }}
</button>
</div>
<p class="text-white/70 text-sm mb-4">
Peers this node dials to bootstrap the FIPS mesh. A cluster with its own anchors doesn't depend on the global public anchor if one is down, the next seeds the DHT instead.
</p>
</div>
</div>
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
<div v-if="anchors.length === 0" class="p-4 rounded-lg bg-white/5 text-sm text-white/60 mb-3">
<p>No seed anchors configured. The daemon will fall back to whatever the upstream FIPS build dials on its own usually the single public anchor, which is fine until it isn't.</p>
<p class="mt-2 text-white/50">Add at least one known-reachable peer (e.g. your VPS or a home node with port-forwarded UDP 8668) to make this cluster self-anchoring.</p>
</div>
<ul v-else class="space-y-2 mb-3">
<li v-for="a in anchors" :key="a.npub" class="p-3 bg-white/5 rounded-lg flex items-start gap-3">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">{{ a.label || 'Unlabeled anchor' }}</p>
<p class="text-xs text-white/60 font-mono break-all">{{ a.npub.slice(0, 20) }}{{ a.npub.slice(-8) }}</p>
<p class="text-xs text-white/50 mt-0.5">{{ a.address }} · {{ a.transport }}</p>
</div>
<button
type="button"
class="shrink-0 text-xs px-2 py-1 rounded-md text-red-300 hover:bg-red-400/10 transition-colors"
:title="`Remove ${a.label || a.npub.slice(0, 12)}`"
@click="removeAnchor(a.npub)"
>Remove</button>
</li>
</ul>
<form class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-auto pt-3 border-t border-white/10 shrink-0" @submit.prevent="addAnchor">
<label class="flex flex-col gap-1 sm:col-span-2">
<span class="text-xs text-white/60">Anchor npub</span>
<input v-model="draft.npub" type="text" placeholder="npub1…" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-white/60">Address (host:port)</span>
<input v-model="draft.address" type="text" placeholder="192.168.1.116:8668" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-white/60">Label (optional)</span>
<input v-model="draft.label" type="text" placeholder="Home anchor" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
</label>
<button type="submit" class="sm:col-span-2 min-h-[44px] glass-button rounded-lg text-sm font-medium disabled:opacity-60" :disabled="adding || !draft.npub || !draft.address">{{ adding ? 'Adding…' : 'Add anchor' }}</button>
</form>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
interface SeedAnchor {
npub: string
address: string
transport: string
label: string
}
interface ApplyResult {
npub: string
ok: boolean
message: string
}
const anchors = ref<SeedAnchor[]>([])
const adding = ref(false)
const applying = ref(false)
const statusMessage = ref('')
const statusIsError = ref(false)
const draft = reactive<Pick<SeedAnchor, 'npub' | 'address' | 'label'>>({
npub: '',
address: '',
label: '',
})
function flash(msg: string, isError = false) {
statusMessage.value = msg
statusIsError.value = isError
setTimeout(() => { statusMessage.value = '' }, 6000)
}
async function load() {
try {
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({ method: 'fips.list-seed-anchors' })
anchors.value = res.seed_anchors
} catch (e: unknown) {
if (import.meta.env.DEV) console.warn('fips.list-seed-anchors failed', e)
}
}
async function addAnchor() {
if (!draft.npub.trim() || !draft.address.trim()) return
adding.value = true
try {
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[]; apply: ApplyResult[] }>({
method: 'fips.add-seed-anchor',
params: {
npub: draft.npub.trim(),
address: draft.address.trim(),
transport: 'udp',
label: draft.label.trim(),
},
})
anchors.value = res.seed_anchors
draft.npub = ''
draft.address = ''
draft.label = ''
const applied = res.apply.find(r => r.ok)
flash(applied ? 'Anchor added and dialed.' : 'Anchor saved — dial failed, will retry on the next apply cycle.', !applied)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Add failed: ${msg}`, true)
} finally {
adding.value = false
}
}
async function removeAnchor(npub: string) {
try {
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({
method: 'fips.remove-seed-anchor',
params: { npub },
})
anchors.value = res.seed_anchors
flash('Anchor removed.')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Remove failed: ${msg}`, true)
}
}
async function applyAll() {
applying.value = true
try {
const res = await rpcClient.call<{ applied: number; results: ApplyResult[] }>({ method: 'fips.apply-seed-anchors' })
const ok = res.results.filter(r => r.ok).length
flash(`${ok} of ${res.applied} anchor${res.applied === 1 ? '' : 's'} dialed.`, ok === 0 && res.applied > 0)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Apply failed: ${msg}`, true)
} finally {
applying.value = false
}
}
onMounted(load)
</script>

View File

@ -180,6 +180,18 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.21-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.21-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>FIPS bootstrap no longer depends on a single public anchor. You can now add your own anchors other archipelago nodes or a VPS you control and the node will dial every one of them to join the mesh on startup. If one anchor is down, the next one seeds the routing layer instead, so a flaky public anchor no longer strands a fresh install.</p>
<p>Anchors persist across restarts and are re-applied every five minutes, so a daemon that got temporarily isolated reconnects on its own without anyone having to SSH in. Each anchor carries an operator-editable label so you can remember which is which.</p>
<p>No behavior change if you don't configure any — the upstream daemon's own defaults keep working as before. This purely adds an operator-controlled list on top.</p>
</div>
</div>
<!-- v1.7.20-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">

View File

@ -1,26 +1,27 @@
{
"version": "1.7.20-alpha",
"version": "1.7.21-alpha",
"release_date": "2026-04-21",
"changelog": [
"Fixed a critical bug where nodes on 'Check & Apply Daily' could end up offline after their nightly update. The scheduler was killing the service a moment too early, before the built-in restart handler could bring the new version back up — leaving the node dead until someone SSH'd in. The scheduler now uses the same restart path as the 'Install Update' button, so auto-applied updates come back online on their own.",
"Applies automatically — no action needed on your end beyond taking this update."
"FIPS bootstrap no longer depends on a single public anchor. You can add your own anchors — other archipelago nodes or a VPS you control — and the node dials every one on startup to join the mesh. If one anchor is down, the next seeds the routing layer instead, so a flaky public anchor no longer strands a fresh install.",
"Anchors persist across restarts and are re-applied every five minutes, so a daemon that got temporarily isolated reconnects on its own without anyone having to SSH in. Each anchor carries an operator-editable label.",
"No behavior change if you don't configure any — the upstream daemon's defaults keep working as before. This purely adds an operator-controlled list on top."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.19-alpha",
"new_version": "1.7.20-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.20-alpha/archipelago",
"sha256": "bf4f8b91b021cad445a868f454707e0fa005446f755604f8c3e072bb7a059e6f",
"size_bytes": 40640016
"current_version": "1.7.20-alpha",
"new_version": "1.7.21-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.21-alpha/archipelago",
"sha256": "47e5ddff3a2eeb3ff7117bfccb1799a72932e77afefb4f03b17679c21858f21c",
"size_bytes": 40799840
},
{
"name": "archipelago-frontend-1.7.20-alpha.tar.gz",
"current_version": "1.7.19-alpha",
"new_version": "1.7.20-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.20-alpha/archipelago-frontend-1.7.20-alpha.tar.gz",
"sha256": "a82f187b597c51e5f3d8753529914651ab2d8e8bb3ad9c36d287b335e4d386a9",
"size_bytes": 162082209
"name": "archipelago-frontend-1.7.21-alpha.tar.gz",
"current_version": "1.7.20-alpha",
"new_version": "1.7.21-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.21-alpha/archipelago-frontend-1.7.21-alpha.tar.gz",
"sha256": "f8fd8f7d07f99fd227c6d3f3a188154b51e20e19b0f2f303175ea46095ae30d9",
"size_bytes": 162082809
}
]
}

Binary file not shown.