Compare commits

...
Author SHA1 Message Date
DorianandClaude Opus 4.7 79f4ec4bde release(v1.7.10-alpha): apply namespace fix + FIPS cascade + profile polish
THE apply fix
  archipelago.service uses ProtectSystem=strict, so /opt and /usr are
  read-only inside the service's mount namespace. sudo inherits that
  namespace — every sudo mkdir/mv/chown from apply_update was hitting
  EROFS even as root. Every prior "Failed to apply update" was a
  symptom of this. New `host_sudo()` helper wraps every filesystem
  call in `sudo systemd-run --wait --collect --pipe -- <cmd>`, which
  spawns a transient unit with systemd's default (no ProtectSystem)
  protections — the command runs in the host namespace and can touch
  /opt/archipelago + /usr/local/bin normally.

FIPS cascade (#2)
  Home.vue and Server.vue both carry a FIPS row that previously only
  looked at {installed, service_active, key_present}. Now they also
  read anchor_connected + authenticated_peer_count and mirror the
  full FIPS card: green "Active · N peers" when healthy, orange "No
  anchor" when the DHT bootstrap has failed.

Profile paste URL fallback (#4)
  Web5Identities.vue list + editor previously had `@error="display:none"`
  on the <img>, which hid the tag without re-rendering the fallback —
  a broken pasted URL showed up blank. Replaced with reactive
  pictureLoadFailed / listPictureFailed flags plus a watcher that
  resets on URL change. Broken URL now falls back to the initial (or
  identicon for seed-derived identities).

Small-upload data URL (#3)
  Uploaded profile pictures ≤ 64 KB are now inlined as
  `data:image/png;base64,...` into profile.picture on the client
  before calling update-profile. That kind-0 event is fetchable by
  any Nostr client — no Tor needed. Larger uploads fall back to the
  onion-rooted public_url with a hint telling the user to paste a
  public https:// URL for broader visibility.

Deferred: #1 FIPS Reconnect "actually fixes" — the current Reconnect
calls fips.restart which clears the daemon state, but when the
anchor is truly unreachable (UDP 8668 blocked by network/ISP), no
amount of restart can help. A richer diagnostic is out of scope for
this bundle.

Artefacts:
  archipelago                                      4a77c704…82aa6f8  40379696
  archipelago-frontend-1.7.10-alpha.tar.gz         0644a436…54f58    76983846

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:46:03 -04:00
DorianandClaude Opus 4.7 61bfdac7cc release(v1.7.9-alpha): OTA proof bump on top of mv-based apply
Version-only bump. First release where .116/.198/.253 (running v1.7.8
with the mv-based apply) should walk through Check → Download →
Install → auto-restart cleanly via UI, no sideload intervention.

Artefacts:
  archipelago                                      1ec7383d…301629  40378536
  archipelago-frontend-1.7.9-alpha.tar.gz          4fb79664…0172e9  76984615 (reused)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:23:37 -04:00
DorianandClaude Opus 4.7 1e648800ad release(v1.7.8-alpha): fix apply ETXTBSY — use mv instead of install
apply_update's binary swap called `sudo install -m 0755 src
/usr/local/bin/archipelago`. install opens the destination for write
with O_TRUNC; the kernel returns ETXTBSY (exit 1) when the path is a
currently-running executable, which it always is during apply because
apply_update is called by the archipelago RPC handler — running as
archipelago itself. Every previous "Failed to apply update" was this
one root cause; the manual sideload path only worked because we
stopped the service first.

rename() doesn't modify the file it replaces — it repoints the path
at a new inode while the old inode stays alive for any process that
has it mapped. `mv` uses rename(). Switched to `sudo mv` (with prior
chmod+chown on the staging file) so the swap is atomic and tolerant
of the running binary.

Frontend tarball byte-identical to v1.7.7-alpha; only the binary
version string changes.

Artefacts:
  archipelago                                      2753daec…48094d  40377648
  archipelago-frontend-1.7.8-alpha.tar.gz          4fb79664…0172e9  76984615 (reused)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:04:09 -04:00
DorianandClaude Opus 4.7 8a2cabdba9 release(v1.7.7-alpha): clean OTA test bump on top of robust apply
Pure version bump. No code changes. First release shipped with the
reinforced apply_update (timestamped staging + all-mv) and frontend
with 95% progress cap — this OTA should walk through cleanly from
.116/.198/.253 without any sideload intervention.

Artefacts:
  archipelago                                      e3f1740d…006025  40373392
  archipelago-frontend-1.7.7-alpha.tar.gz          4fb79664…0172e9  76984615 (reused)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:44:19 -04:00
DorianandClaude Opus 4.7 4d1bd063e8 release(v1.7.6-alpha): robust apply_update + manifest-override env var
apply_update frontend swap
  Transient EROFS on .198 (filesystem hiccup — root FS mounts with
  errors=remount-ro so a fleeting glitch can bounce /opt to RO for a
  moment) caught the pre-cleanup `rm -rf web-ui.new web-ui.bak` mid-
  stride and aborted the apply. Rewrote the swap to use a timestamped
  staging dir (web-ui.new.<ms>) and a timestamped old-copy path so
  nothing needs to be rm'd before the extract. After the new tree is
  mv'd into place, the previous rollback copy is rotated aside with a
  .<ms> suffix (best-effort) and this apply's old copy becomes the new
  web-ui.bak. If the final mv fails, the staged old is restored so
  nginx keeps serving.

handle_update_check manifest override
  handle_update_check takes the git path whenever ~/archy/.git exists.
  On the dev box (.116) that meant the Pull & Rebuild button was
  always the only option even though the manifest-path OTA was
  already wired via ARCHIPELAGO_UPDATE_URL. Now: if that env var is
  set, we skip the git detection entirely and use the manifest path.
  The regular fleet (no env var, no repo) hits the manifest branch
  naturally; beta dev nodes (repo + no env var) still get Pull &
  Rebuild; dev nodes with the env var explicitly set can finally test
  the manifest OTA end-to-end.

Artefacts:
  archipelago                                      356e78cc…91a6dd  40372288
  archipelago-frontend-1.7.6-alpha.tar.gz          4fb79664…0172e9  76984615 (reused)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:33:10 -04:00
DorianandClaude Opus 4.7 b8397b5ecb release(v1.7.5-alpha): OTA end-to-end test bump
Trivial version-only bump. No code changes; binary differs only in its
embedded CARGO_PKG_VERSION string. Frontend tarball is byte-identical
to v1.7.4-alpha's (same sha), copied under the new filename to satisfy
the manifest component naming.

This exists so the fleet nodes (.116/.198/.253), all now running
v1.7.4-alpha with the fixed apply_update tar flow, can exercise the
full OTA pipeline from the UI: Check → Download (30-min timeout) →
Install (sudo install binary + sudo tar to web-ui.new + atomic swap) →
auto-restart (systemctl --no-block) → sidebar updates → state sync.

Artefacts:
  archipelago                                      7422a695…a1a2a6  40362432
  archipelago-frontend-1.7.5-alpha.tar.gz          4fb79664…0172e9  76984615 (reused)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:10:50 -04:00
DorianandClaude Opus 4.7 1e7df417a4 release(v1.7.4-alpha): fix Install Update tar extraction + progress overshoot
apply_update was extracting the frontend tarball with
`tar -xzf -C /opt/archipelago`, but the tar contents are the *inside*
of web-ui/ (root entries are ./test-aiui.html, ./assets/, etc.). So
the files landed directly in /opt/archipelago instead of under web-ui/,
and tar bailed on nginx-owned paths mid-extraction. First end-to-end
OTA test (.198) found it: "tar: ./assets/SystemUpdate-…js: Cannot
open: No such file or directory".

Now extracts into web-ui.new, chowns, then atomically swaps: move
existing web-ui → web-ui.bak, then web-ui.new → web-ui. Same pattern
as the manual sideload that's been working.

Frontend: SystemUpdate.vue fake download progress was capped at "<90"
with a Math.random()*15 increment — the last tick could push to
~104.99%. Capped at 95% with a smaller step so it stops at 95 and the
real RPC completion jumps it to 100.

Artefacts:
  archipelago                                      a14ad7e4…2a2be3  40361984
  archipelago-frontend-1.7.4-alpha.tar.gz          4fb79664…0172e9  76984615

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:02:14 -04:00
DorianandClaude Opus 4.7 3db71adb55 release(v1.7.3-alpha): sidebar version sync + FIPS reconnect + profile pic render
Sidebar version
  detect_build_version() no longer reads /opt/archipelago/build-info.txt
  first. That file was written by the ISO installer at flash time and
  never rewritten by OTA or sideload, so after any binary swap the
  sidebar kept advertising whatever the ISO shipped with. Now just
  returns env!("CARGO_PKG_VERSION") unconditionally — always matches the
  running binary.

FIPS card
  The two-column grid in FipsNetworkCard.vue placed version/npub boxes
  side-by-side on mobile but the anchor-status panel forced col-span-2,
  creating an unbalanced empty column at every desktop width. Anchor
  status moves to its own full-width row below the grid. When the
  anchor is not reached, a "Reconnect" button appears next to the
  status line; it calls fips.restart (45s timeout), waits 5s for the
  daemon to come back, then reloads fips.status. Surfaces whether the
  restart actually recovered the anchor in a status flash.

Profile picture render
  Uploaded profile pictures are stored with an onion-rooted URL so
  external Nostr clients can fetch them. The local browser isn't
  Tor-routed though, so the <img src> silently 404'd and the UI fell
  back to showing initials. Added a displayableUrl() helper on
  Web5Identities.vue that rewrites http://<onion>/blob/<cid>[?...] to
  same-origin /blob/<cid> for rendering, while the stored URL keeps
  its onion prefix so publishing to Nostr still works for external
  viewers. Pass-through for data: URLs and already-relative paths.

Identity row title
  The identity list header now renders profile.display_name (when set)
  and keeps identity.name as a muted parenthetical. Before, only the
  internal name was shown and a user who'd customised their Nostr
  display_name saw a mismatch between their own UI and what peers
  rendered.

Artefacts:
  archipelago                                      99184b95…22dc1b  40350664
  archipelago-frontend-1.7.3-alpha.tar.gz          7b933cf4…74a8bc  76987031

Changelog layman-style per the saved feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:44:59 -04:00
DorianandClaude Opus 4.7 66b4e2b313 release(v1.7.2-alpha): fix Install Update + identity avatar backfill + label
Three user-visible fixes shipped together.

1. update.apply permission-denied
   apply_update() was doing fs::copy into /usr/local/bin/archipelago and
   tar xzf into /opt/archipelago as the archipelago user — both root-owned.
   The backup step succeeded (it wrote to data_dir) but the swap failed
   with a silent permission denied, wrapped as "Failed to apply archipelago".
   Now uses `sudo install -m 0755` for the binary and `sudo tar -xzf` for
   the frontend, plus a post-apply `sudo systemctl --no-block restart
   archipelago` scheduled 2s after the RPC reply so the UI sees success.

2. Apply → Install label
   en/es locale strings: applyUpdate / applyTitle / applyNow changed from
   "Apply" to "Install". Matches the user's mental model and distinguishes
   the user-facing verb from the internal apply_update() function.

3. Identity avatar backfill
   Identities created before df83163f had profile=None on disk and so
   rendered as initials. load_record() now synthesizes an IdentityProfile
   with a default picture (identicon for regular identities, the hex node
   SVG for derivation_index=0) when profile is missing. The synthetic
   profile lives only in the returned record; the file stays untouched so
   a later explicit Save persists whatever the user actually chose.

Artefacts:
  archipelago                                        70e5444e…67c589  40381960
  archipelago-frontend-1.7.2-alpha.tar.gz            806b027b…358a824 76983699

Changelog rewritten layman-style per saved feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:25:10 -04:00
13 changed files with 318 additions and 98 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.1-alpha"
version = "1.7.10-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.1-alpha"
version = "1.7.10-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+8 -2
View File
@@ -6,12 +6,18 @@ impl RpcHandler {
/// Check for available system updates.
/// Tries git-based check first (if repo exists), falls back to manifest-based.
pub(super) async fn handle_update_check(&self) -> Result<serde_json::Value> {
// Try git-based check first (preferred for beta nodes)
// Manifest override: when ARCHIPELAGO_UPDATE_URL is explicitly set,
// the operator wants OTA via manifest — typically a dev box where
// ~/archy/.git exists but isn't the intended update surface.
// Without this short-circuit the dev box always advertises "Pull
// & Rebuild" and can never exercise the manifest OTA path.
let manifest_override = std::env::var("ARCHIPELAGO_UPDATE_URL").is_ok();
let repo_dir = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
)
.join("archy");
if repo_dir.join(".git").exists() {
if !manifest_override && repo_dir.join(".git").exists() {
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
return Ok(git_status);
}
+7 -10
View File
@@ -265,16 +265,13 @@ impl DataModel {
/// falling back to Cargo.toml version. This allows sequential CI build
/// numbers to be reflected in the UI without recompiling the binary.
fn detect_build_version() -> String {
if let Ok(content) = std::fs::read_to_string("/opt/archipelago/build-info.txt") {
for line in content.lines() {
if let Some(v) = line.strip_prefix("version=") {
let v = v.trim();
if !v.is_empty() {
return v.to_string();
}
}
}
}
// Always use the binary's compiled-in version. The ISO installer
// writes /opt/archipelago/build-info.txt at install time, but that
// file is never rewritten by OTA or sideload, so trusting it made
// the sidebar permanently advertise whatever the ISO shipped with
// even after the running binary had moved on. CARGO_PKG_VERSION is
// baked into the binary at compile time, so it always matches what
// is actually running.
env!("CARGO_PKG_VERSION").to_string()
}
+15 -1
View File
@@ -739,6 +739,20 @@ impl IdentityManager {
.and_then(|pk| pk.to_bech32().ok())
});
// Backfill a default avatar for identities created before the
// default-avatar feature shipped. The synthetic profile lives only
// in the returned record — we don't rewrite the file on disk,
// since a later explicit save will persist whatever the user
// actually chose. Master identities (seed index 0) get the hex
// node SVG; all other pre-existing identities get the identicon.
let profile = file.profile.or_else(|| {
let is_master = file.derivation_index == Some(0);
Some(IdentityProfile {
picture: Some(crate::avatar::default_picture(&file.pubkey_hex, is_master)),
..Default::default()
})
});
Ok(IdentityRecord {
id: file.id,
name: file.name,
@@ -749,7 +763,7 @@ impl IdentityManager {
created_at: file.created_at,
nostr_pubkey: file.nostr_pubkey_hex,
nostr_npub,
profile: file.profile,
profile,
})
}
+128 -32
View File
@@ -244,6 +244,32 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
})
}
/// Run a command as root, but *outside* the archipelago service's
/// restricted mount namespace.
///
/// archipelago.service uses `ProtectSystem=strict`, which makes `/opt`
/// and `/usr` read-only inside the service — and sudo inherits the
/// namespace, so `sudo mv /opt/archipelago/...` fails with EROFS even
/// though sudo itself is root. `systemd-run --wait` spawns a transient
/// service unit that inherits systemd's default protections (i.e. none
/// of ours), escaping the namespace.
async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
let mut full: Vec<&str> = vec![
"systemd-run",
"--wait",
"--quiet",
"--collect",
"--pipe",
"--",
];
full.extend_from_slice(args);
tokio::process::Command::new("sudo")
.args(&full)
.status()
.await
.context("sudo systemd-run spawn failed")
}
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
pub async fn apply_update(data_dir: &Path) -> Result<()> {
let staging_dir = data_dir.join("update-staging");
@@ -277,46 +303,103 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
match name.as_str() {
"archipelago" => {
let dest = Path::new("/usr/local/bin/archipelago");
fs::copy(&src, dest)
// Two namespace gotchas this block works around:
// 1. We're running FROM /usr/local/bin/archipelago, so
// `install`/`cp` (O_TRUNC + write) fail with ETXTBSY.
// Use `mv`, which is atomic rename() and tolerates a
// busy destination.
// 2. archipelago.service sets ProtectSystem=strict, so
// even `sudo mv` into /usr/local/bin/ fails EROFS —
// sudo inherits the service's mount namespace. Route
// the rename through systemd-run so it runs in a
// transient unit with default protections.
let staged = src.to_string_lossy().to_string();
let _ = host_sudo(&["chmod", "0755", &staged]).await;
let _ = host_sudo(&["chown", "root:root", &staged]).await;
let status = host_sudo(&["mv", &staged, "/usr/local/bin/archipelago"])
.await
.with_context(|| format!("Failed to apply {}", name))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o755))
.context("Failed to set binary permissions")?;
.with_context(|| format!("Failed to spawn mv for {}", name))?;
if !status.success() {
anyhow::bail!(
"mv into /usr/local/bin failed for {} (exit {:?})",
name,
status.code()
);
}
info!(name = %name, "Backend binary applied");
}
_ if name.contains("frontend") && name.ends_with(".tar.gz") => {
let web_ui_dir = Path::new("/opt/archipelago/web-ui");
// Back up current frontend
let frontend_backup = backup_dir.join("web-ui-backup.tar.gz");
if web_ui_dir.exists() {
let status = tokio::process::Command::new("tar")
.args([
"-czf",
&frontend_backup.to_string_lossy(),
"-C",
"/opt/archipelago",
"web-ui",
])
.status()
// Tarball contents are the *inside* of web-ui/ (root entries
// `./test-aiui.html`, `./assets/`, ...). Extract into a
// uniquely-named staging dir, then mv into place. No `rm
// -rf` pre-cleanup — that's what hit transient EROFS on
// .198 and aborted the apply mid-flight.
let ts = chrono::Utc::now().timestamp_millis();
let staging_new = format!("/opt/archipelago/web-ui.new.{}", ts);
let staging_old = format!("/opt/archipelago/web-ui.old.{}", ts);
let web_ui = "/opt/archipelago/web-ui";
let backup_path = "/opt/archipelago/web-ui.bak";
// All sudo calls that touch /opt/archipelago go through
// host_sudo so they see a normal root mount namespace.
let mk = host_sudo(&["mkdir", "-p", &staging_new])
.await
.context("Failed to create frontend staging dir")?;
if !mk.success() {
anyhow::bail!("mkdir {} failed", staging_new);
}
let extract = host_sudo(&[
"tar",
"-xzf",
&src.to_string_lossy(),
"-C",
&staging_new,
])
.await
.with_context(|| format!("Failed to extract {}", name))?;
if !extract.success() {
let _ = host_sudo(&["rm", "-rf", &staging_new]).await;
anyhow::bail!("tar extraction failed for {}", name);
}
let _ = host_sudo(&[
"chown",
"-R",
"archipelago:archipelago",
&staging_new,
])
.await;
// Swap: mv current web-ui aside, then mv new into place.
if Path::new(web_ui).exists() {
let mv_old = host_sudo(&["mv", web_ui, &staging_old])
.await
.context("Failed to backup frontend")?;
if status.success() {
info!("Frontend backed up");
.context("Failed to rotate old web-ui")?;
if !mv_old.success() {
anyhow::bail!("failed to move old web-ui aside");
}
}
// Extract new frontend
let status = tokio::process::Command::new("tar")
.args(["-xzf", &src.to_string_lossy(), "-C", "/opt/archipelago"])
.status()
let mv_new = host_sudo(&["mv", &staging_new, web_ui])
.await
.with_context(|| format!("Failed to extract {}", name))?;
if !status.success() {
anyhow::bail!("tar extraction failed for {}", name);
.context("Failed to swap new web-ui into place")?;
if !mv_new.success() {
if Path::new(&staging_old).exists() {
let _ = host_sudo(&["mv", &staging_old, web_ui]).await;
}
anyhow::bail!("failed to move new web-ui into place");
}
// Rotate previous rollback aside and install this apply's
// old copy as the new rollback.
if Path::new(&staging_old).exists() {
if Path::new(backup_path).exists() {
let _ = host_sudo(&[
"mv",
backup_path,
&format!("{}.{}", backup_path, ts),
])
.await;
}
let _ = host_sudo(&["mv", &staging_old, backup_path]).await;
}
info!(name = %name, "Frontend archive extracted to /opt/archipelago/web-ui");
}
@@ -339,7 +422,20 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
// Clean staging
let _ = fs::remove_dir_all(&staging_dir).await;
info!("Update applied. Restart service to take effect.");
info!("Update applied — scheduling service restart in 2s so the RPC reply lands first");
// Restart asynchronously so the JSON-RPC response actually reaches the
// UI before systemd kills us. --no-block makes sure systemctl doesn't
// try to wait for the current service (us) to exit cleanly before
// starting the new process — it would deadlock otherwise.
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
// systemctl talks to PID 1 over D-Bus — doesn't need the host
// mount namespace, but routing through host_sudo keeps the
// apply flow's sudo calls uniform.
let _ = host_sudo(&["systemctl", "--no-block", "restart", "archipelago"]).await;
});
Ok(())
}
+3 -3
View File
@@ -663,18 +663,18 @@
"autoApply": "Auto-Apply",
"autoApplyDesc": "Check daily and automatically install updates at 3 AM. Service restarts as needed.",
"downloadUpdate": "Download Update",
"applyUpdate": "Apply Update",
"applyUpdate": "Install Update",
"checkForUpdates": "Check for Updates",
"checking": "Checking...",
"rollback": "Rollback to Previous",
"backToSettings": "Back to Settings",
"percentComplete": "{percent}% complete",
"applyWarning": "Installing components and restarting services. Do not power off.",
"applyTitle": "Apply Update?",
"applyTitle": "Install Update?",
"applyMessage": "The backend service will restart. This may take a moment.",
"rollbackTitle": "Rollback Version?",
"rollbackMessage": "This will restore the previous version. The backend service will restart.",
"applyNow": "Apply Now",
"applyNow": "Install Now",
"rollbackButton": "Rollback",
"upToDateMessage": "Your system is up to date. No updates available. Your system is running the latest version.",
"checkFailed": "Failed to check for updates. Check your internet connection.",
+3 -3
View File
@@ -662,18 +662,18 @@
"autoApply": "Aplicaci\u00f3n autom\u00e1tica",
"autoApplyDesc": "Buscar diariamente y aplicar actualizaciones autom\u00e1ticamente a las 3 AM. Los servicios se reinician seg\u00fan sea necesario.",
"downloadUpdate": "Descargar actualizaci\u00f3n",
"applyUpdate": "Aplicar actualizaci\u00f3n",
"applyUpdate": "Instalar actualizaci\u00f3n",
"checkForUpdates": "Buscar actualizaciones",
"checking": "Verificando...",
"rollback": "Revertir a la versi\u00f3n anterior",
"backToSettings": "Volver a configuraci\u00f3n",
"percentComplete": "{percent}% completado",
"applyWarning": "Instalando componentes y reiniciando servicios. No apague el equipo.",
"applyTitle": "\u00bfAplicar actualizaci\u00f3n?",
"applyTitle": "\u00bfInstalar actualizaci\u00f3n?",
"applyMessage": "El servicio del backend se reiniciar\u00e1. Esto puede tomar un momento.",
"rollbackTitle": "\u00bfRevertir versi\u00f3n?",
"rollbackMessage": "Esto restaurar\u00e1 la versi\u00f3n anterior. El servicio del backend se reiniciar\u00e1.",
"applyNow": "Aplicar ahora",
"applyNow": "Instalar ahora",
"rollbackButton": "Revertir",
"upToDateMessage": "Su sistema est\u00e1 actualizado. No hay actualizaciones disponibles. Su sistema est\u00e1 ejecutando la \u00faltima versi\u00f3n.",
"checkFailed": "Error al buscar actualizaciones. Verifique su conexi\u00f3n a internet.",
+17 -8
View File
@@ -317,26 +317,35 @@ const torConnected = computed(() => {
})
const vpnStatus = ref({ connected: false, provider: '' })
const vpnConnected = computed(() => vpnStatus.value.connected || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null)
const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
const fipsDotClass = computed(() => {
const s = fipsStatus.value
if (!s || !s.installed) return 'bg-white/40'
if (s.service_active) return 'bg-green-400'
return 'bg-white/40'
if (!s.service_active) return 'bg-white/40'
// Active but no anchor = degraded, not fully green
if (s.anchor_connected === false) return 'bg-orange-400'
return 'bg-green-400'
})
const fipsTextClass = computed(() => {
const s = fipsStatus.value
if (!s || !s.installed) return 'text-white/40'
if (s.service_active) return 'text-green-400'
return 'text-white/40'
if (!s.service_active) return 'text-white/40'
if (s.anchor_connected === false) return 'text-orange-400'
return 'text-green-400'
})
const fipsStatusLabel = computed(() => {
const s = fipsStatus.value
if (!s) return '…'
if (!s.installed) return 'Not installed'
if (s.service_active) return 'Active'
if (!s.key_present) return 'Awaiting seed'
return 'Inactive'
if (!s.service_active) {
if (!s.key_present) return 'Awaiting seed'
return 'Inactive'
}
// Service is active — reflect anchor reachability in the label so the
// Home and Server rows flip in sync with the FIPS card.
if (s.anchor_connected === false) return 'No anchor'
const peers = s.authenticated_peer_count ?? 0
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
})
const bitcoinSyncDisplay = computed(() => {
if (!systemStats.bitcoinAvailable) return 'Not running'
+14 -8
View File
@@ -420,25 +420,31 @@ const networkData = ref({
})
// FIPS status row for the Local Network card. Full FIPS card lives below.
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null)
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
const fipsRowLabel = computed(() => {
const s = fipsSummary.value
if (!s) return '…'
if (!s.installed) return 'Not installed'
// Service-active wins even on legacy nodes with no seed-derived key.
if (s.service_active) return 'Active'
if (!s.key_present) return 'Awaiting seed'
return 'Inactive'
if (!s.service_active) {
if (!s.key_present) return 'Awaiting seed'
return 'Inactive'
}
// Service is active — reflect anchor reachability so the row flips in
// sync with the full FIPS card below.
if (s.anchor_connected === false) return 'No anchor'
const peers = s.authenticated_peer_count ?? 0
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
})
const fipsRowTextClass = computed(() => {
const s = fipsSummary.value
if (!s || !s.installed) return 'text-white/40'
if (s.service_active) return 'text-green-400'
return 'text-white/60'
if (!s.service_active) return 'text-white/60'
if (s.anchor_connected === false) return 'text-orange-400'
return 'text-green-400'
})
async function loadFipsSummary() {
try {
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean }>({ method: 'fips.status' })
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ method: 'fips.status' })
} catch { /* backend too old */ }
}
+5 -3
View File
@@ -345,10 +345,12 @@ async function downloadUpdate() {
downloadPercent.value = 0
statusMessage.value = ''
// Simulate incremental progress while waiting for the RPC
// Simulate incremental progress while waiting for the RPC. Capped at
// 95% so the bar never shows >100% before the real completion jumps it
// to 100 — previously the random increment could overshoot.
const progressInterval = setInterval(() => {
if (downloadPercent.value < 90) {
downloadPercent.value += Math.random() * 15
if (downloadPercent.value < 95) {
downloadPercent.value = Math.min(95, downloadPercent.value + Math.random() * 3)
}
}, 500)
+53 -16
View File
@@ -18,27 +18,12 @@
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3 flex-1 min-h-0">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3 shrink-0">
<div class="p-3 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Daemon version</p>
<p class="text-sm font-medium text-white break-all">{{ status.version || '—' }}</p>
<p v-if="!status.installed" class="text-xs text-white/40 mt-1">Package not installed</p>
</div>
<div v-if="status.service_active" class="p-3 bg-white/5 rounded-lg sm:col-span-2">
<div class="flex items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :class="status.anchor_connected ? 'bg-cyan-400' : 'bg-orange-400'"></span>
<span class="text-white/70">Anchor (fips.v0l.io):</span>
<span :class="status.anchor_connected ? 'text-cyan-300' : 'text-orange-300'">
{{ status.anchor_connected ? 'connected' : 'not reached' }}
</span>
</div>
<div class="text-white/60">{{ status.authenticated_peer_count ?? 0 }} peer{{ (status.authenticated_peer_count ?? 0) === 1 ? '' : 's' }}</div>
</div>
<p v-if="!status.anchor_connected" class="mt-1 text-[11px] text-white/40">
Without the anchor, DHT routing to unknown npubs can't bootstrap; federation + messaging will fall back to Tor until it reconnects.
</p>
</div>
<div class="p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between gap-2 mb-1">
<p class="text-xs text-white/60">FIPS npub</p>
@@ -60,6 +45,33 @@
</div>
</div>
<!-- Anchor status: always full-width to keep desktop layout tidy -->
<div v-if="status.service_active" class="p-3 bg-white/5 rounded-lg mb-3 shrink-0">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-2 text-xs">
<span class="w-2 h-2 rounded-full" :class="status.anchor_connected ? 'bg-cyan-400' : 'bg-orange-400'"></span>
<span class="text-white/70">Anchor (fips.v0l.io):</span>
<span :class="status.anchor_connected ? 'text-cyan-300' : 'text-orange-300'">
{{ status.anchor_connected ? 'connected' : 'not reached' }}
</span>
<span class="text-white/40">·</span>
<span class="text-white/60">{{ status.authenticated_peer_count ?? 0 }} peer{{ (status.authenticated_peer_count ?? 0) === 1 ? '' : 's' }}</span>
</div>
<button
v-if="!status.anchor_connected"
type="button"
class="text-xs px-3 py-1.5 rounded-md bg-orange-400/15 hover:bg-orange-400/25 text-orange-200 disabled:opacity-60 transition-colors"
:disabled="reconnecting"
@click="reconnectAnchor"
>
{{ reconnecting ? 'Reconnecting…' : 'Reconnect' }}
</button>
</div>
<p v-if="!status.anchor_connected" class="mt-2 text-[11px] text-white/40 leading-snug">
Without the anchor, DHT routing to unknown npubs can't bootstrap; federation and messaging fall back to Tor until it reconnects. Reconnect restarts the FIPS daemon, which usually clears a stale identity cache.
</p>
</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="status.key_present && !status.service_active" class="flex gap-2 mt-auto pt-3 shrink-0">
@@ -97,6 +109,7 @@ const status = ref<FipsStatus>({
anchor_connected: false,
})
const installing = ref(false)
const reconnecting = ref(false)
const statusMessage = ref('')
const statusIsError = ref(false)
const copied = ref(false)
@@ -167,5 +180,29 @@ async function installAndActivate() {
}
}
// Restart the FIPS daemon to kick it back onto the public anchor. Stale
// identity-cache entries are the usual cause of "not reached"; systemctl
// restart clears them and re-runs the bootstrap handshake.
async function reconnectAnchor() {
reconnecting.value = true
try {
await rpcClient.call({ method: 'fips.restart', timeout: 45_000 })
// Give the daemon a few seconds to come back and re-populate its
// identity cache before we re-query status.
await new Promise((resolve) => setTimeout(resolve, 5000))
await loadStatus()
if (status.value.anchor_connected) {
flash('Anchor reconnected')
} else {
flash('FIPS restarted — anchor still reporting unreachable. Check network / firewall.', true)
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Reconnect failed: ${msg}`, true)
} finally {
reconnecting.value = false
}
}
onMounted(loadStatus)
</script>
+63 -10
View File
@@ -68,8 +68,13 @@
>
<!-- Avatar -->
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
<img v-if="identity.profile?.picture" :src="identity.profile.picture" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
<div v-if="!identity.profile?.picture" class="w-full h-full flex items-center justify-center" :class="{
<img
v-if="identity.profile?.picture && !listPictureFailed[identity.id]"
:src="displayableUrl(identity.profile.picture)"
class="w-full h-full object-cover"
@error="() => { listPictureFailed[identity.id] = true }"
/>
<div v-if="!identity.profile?.picture || listPictureFailed[identity.id]" class="w-full h-full flex items-center justify-center" :class="{
'bg-blue-500/20': identity.purpose === 'personal',
'bg-orange-500/20': identity.purpose === 'business',
'bg-purple-500/20': identity.purpose === 'anonymous',
@@ -88,7 +93,8 @@
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-white font-medium text-sm">{{ identity.name }}</span>
<span class="text-white font-medium text-sm">{{ identity.profile?.display_name || identity.name }}</span>
<span v-if="identity.profile?.display_name && identity.profile.display_name !== identity.name" class="text-white/40 text-xs truncate max-w-[160px]" :title="`Internal name: ${identity.name}`">({{ identity.name }})</span>
<span v-if="identity.is_default" class="text-yellow-400 text-xs" title="Default identity">&#9733;</span>
<span class="text-xs px-2 py-0.5 rounded-full capitalize" :class="{
'bg-blue-500/20 text-blue-300': identity.purpose === 'personal',
@@ -301,8 +307,14 @@
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
<div class="flex items-center gap-3 mb-5">
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
<img v-if="profileForm.picture" :src="profileForm.picture" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
<div v-else class="w-full h-full flex items-center justify-center">
<img
v-if="profileForm.picture && !editorPictureFailed"
:src="displayableUrl(profileForm.picture)"
class="w-full h-full object-cover"
@error="editorPictureFailed = true"
@load="editorPictureFailed = false"
/>
<div v-if="!profileForm.picture || editorPictureFailed" class="w-full h-full flex items-center justify-center">
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
</div>
</div>
@@ -367,7 +379,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { safeClipboardWrite } from './utils'
@@ -408,10 +420,38 @@ const profilePublishing = ref(false)
const avatarUploading = ref(false)
const bannerUploading = ref(false)
// Upload to the node's blob store and drop the returned public URL into
// the profile field. The /api/blob endpoint marks these blobs public, so
// the URL served back (`public_url`, onion-rooted when Tor is up) is
// reachable by external Nostr clients fetching kind:0 metadata.
// Track image load failures so the UI can fall back to the initial/
// identicon placeholder instead of showing a blank square. Pasted URLs
// that 404 (or point at an onion the local browser can't reach) were
// previously silently hidden by a display:none handler that left the
// fallback unrendered.
const editorPictureFailed = ref(false)
const listPictureFailed = reactive<Record<string, boolean>>({})
// Reset the failure flag when the URL changes so a freshly pasted URL
// gets re-tried (the watcher fires once the form reacts).
watch(() => profileForm.value.picture, () => { editorPictureFailed.value = false })
// The backend returns onion-based public URLs for uploaded profile
// pictures (so they're fetchable by external Nostr clients), but the
// local browser session isn't Tor-routed and can't resolve .onion hosts.
// Rewrite onion-rooted `/blob/<cid>` URLs (with or without capability
// query) to same-origin `/blob/<cid>` so they render in this UI. Data
// URLs and plain external URLs pass through untouched.
function displayableUrl(url: string | null | undefined): string {
if (!url) return ''
if (url.startsWith('data:') || url.startsWith('/')) return url
const onionMatch = url.match(/^https?:\/\/[a-z2-7]{16,56}\.onion(\/blob\/[0-9a-f]{64})(\?.*)?$/i)
if (onionMatch && onionMatch[1]) return onionMatch[1]
return url
}
// Upload to the node's blob store and drop a URL into the profile field.
// For small images (≤64KB) we inline the bytes as a data URL so external
// Nostr clients can render the picture without needing to reach a tor
// onion. Larger uploads fall back to the onion-rooted public_url.
const INLINE_MAX = 64 * 1024
async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
const input = ev.target as HTMLInputElement
const file = input?.files?.[0]
@@ -421,6 +461,14 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
profileError.value = ''
try {
const buf = await file.arrayBuffer()
// Inline small images as a data URL — universally fetchable by any
// Nostr client and bypasses the "only reachable over Tor" limitation.
if (buf.byteLength <= INLINE_MAX) {
const mime = file.type || 'image/png'
const b64 = btoa(Array.from(new Uint8Array(buf), (b) => String.fromCharCode(b)).join(''))
profileForm.value[field] = `data:${mime};base64,${b64}`
return
}
const resp = await fetch('/api/blob', {
method: 'POST',
credentials: 'include',
@@ -436,6 +484,11 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
const url = public_url || self_test_url
if (!url) throw new Error('blob API returned no URL')
profileForm.value[field] = url
// Heads-up for large uploads: onion URLs only render on Tor-routed
// clients. Not an error, but worth telling the user.
if (url.includes('.onion/')) {
profileError.value = 'Large image stored on this node. Pasting a public https://… URL is recommended for Nostr visibility.'
}
} catch (e: unknown) {
profileError.value = e instanceof Error ? e.message : `${field} upload failed`
} finally {