Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79f4ec4bde | ||
|
|
61bfdac7cc | ||
|
|
1e648800ad | ||
|
|
8a2cabdba9 | ||
|
|
4d1bd063e8 | ||
|
|
b8397b5ecb | ||
|
|
1e7df417a4 | ||
|
|
3db71adb55 |
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.2-alpha"
|
version = "1.7.10-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.2-alpha"
|
version = "1.7.10-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
authors = ["Archipelago Team"]
|
authors = ["Archipelago Team"]
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ impl RpcHandler {
|
|||||||
/// Check for available system updates.
|
/// Check for available system updates.
|
||||||
/// Tries git-based check first (if repo exists), falls back to manifest-based.
|
/// 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> {
|
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(
|
let repo_dir = std::path::PathBuf::from(
|
||||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
||||||
)
|
)
|
||||||
.join("archy");
|
.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 {
|
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
|
||||||
return Ok(git_status);
|
return Ok(git_status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,16 +265,13 @@ impl DataModel {
|
|||||||
/// falling back to Cargo.toml version. This allows sequential CI build
|
/// falling back to Cargo.toml version. This allows sequential CI build
|
||||||
/// numbers to be reflected in the UI without recompiling the binary.
|
/// numbers to be reflected in the UI without recompiling the binary.
|
||||||
fn detect_build_version() -> String {
|
fn detect_build_version() -> String {
|
||||||
if let Ok(content) = std::fs::read_to_string("/opt/archipelago/build-info.txt") {
|
// Always use the binary's compiled-in version. The ISO installer
|
||||||
for line in content.lines() {
|
// writes /opt/archipelago/build-info.txt at install time, but that
|
||||||
if let Some(v) = line.strip_prefix("version=") {
|
// file is never rewritten by OTA or sideload, so trusting it made
|
||||||
let v = v.trim();
|
// the sidebar permanently advertise whatever the ISO shipped with
|
||||||
if !v.is_empty() {
|
// even after the running binary had moved on. CARGO_PKG_VERSION is
|
||||||
return v.to_string();
|
// baked into the binary at compile time, so it always matches what
|
||||||
}
|
// is actually running.
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
env!("CARGO_PKG_VERSION").to_string()
|
env!("CARGO_PKG_VERSION").to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+115
-55
@@ -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.
|
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
||||||
pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||||
let staging_dir = data_dir.join("update-staging");
|
let staging_dir = data_dir.join("update-staging");
|
||||||
@@ -277,27 +303,25 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
|||||||
|
|
||||||
match name.as_str() {
|
match name.as_str() {
|
||||||
"archipelago" => {
|
"archipelago" => {
|
||||||
// /usr/local/bin is root-owned; archipelago user can't
|
// Two namespace gotchas this block works around:
|
||||||
// fs::copy into it directly. Use sudo install which handles
|
// 1. We're running FROM /usr/local/bin/archipelago, so
|
||||||
// the copy, mode, and ownership atomically.
|
// `install`/`cp` (O_TRUNC + write) fail with ETXTBSY.
|
||||||
let status = tokio::process::Command::new("sudo")
|
// Use `mv`, which is atomic rename() and tolerates a
|
||||||
.args([
|
// busy destination.
|
||||||
"install",
|
// 2. archipelago.service sets ProtectSystem=strict, so
|
||||||
"-m",
|
// even `sudo mv` into /usr/local/bin/ fails EROFS —
|
||||||
"0755",
|
// sudo inherits the service's mount namespace. Route
|
||||||
"-o",
|
// the rename through systemd-run so it runs in a
|
||||||
"root",
|
// transient unit with default protections.
|
||||||
"-g",
|
let staged = src.to_string_lossy().to_string();
|
||||||
"root",
|
let _ = host_sudo(&["chmod", "0755", &staged]).await;
|
||||||
&src.to_string_lossy(),
|
let _ = host_sudo(&["chown", "root:root", &staged]).await;
|
||||||
"/usr/local/bin/archipelago",
|
let status = host_sudo(&["mv", &staged, "/usr/local/bin/archipelago"])
|
||||||
])
|
|
||||||
.status()
|
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("Failed to spawn install for {}", name))?;
|
.with_context(|| format!("Failed to spawn mv for {}", name))?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"sudo install failed for {} (exit {:?})",
|
"mv into /usr/local/bin failed for {} (exit {:?})",
|
||||||
name,
|
name,
|
||||||
status.code()
|
status.code()
|
||||||
);
|
);
|
||||||
@@ -305,42 +329,78 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
|||||||
info!(name = %name, "Backend binary applied");
|
info!(name = %name, "Backend binary applied");
|
||||||
}
|
}
|
||||||
_ if name.contains("frontend") && name.ends_with(".tar.gz") => {
|
_ if name.contains("frontend") && name.ends_with(".tar.gz") => {
|
||||||
let web_ui_dir = Path::new("/opt/archipelago/web-ui");
|
// Tarball contents are the *inside* of web-ui/ (root entries
|
||||||
// Back up current frontend. /opt/archipelago is root-owned;
|
// `./test-aiui.html`, `./assets/`, ...). Extract into a
|
||||||
// the backup goes under our data_dir where we can write.
|
// uniquely-named staging dir, then mv into place. No `rm
|
||||||
let frontend_backup = backup_dir.join("web-ui-backup.tar.gz");
|
// -rf` pre-cleanup — that's what hit transient EROFS on
|
||||||
if web_ui_dir.exists() {
|
// .198 and aborted the apply mid-flight.
|
||||||
let status = tokio::process::Command::new("sudo")
|
let ts = chrono::Utc::now().timestamp_millis();
|
||||||
.args([
|
let staging_new = format!("/opt/archipelago/web-ui.new.{}", ts);
|
||||||
"tar",
|
let staging_old = format!("/opt/archipelago/web-ui.old.{}", ts);
|
||||||
"-czf",
|
let web_ui = "/opt/archipelago/web-ui";
|
||||||
&frontend_backup.to_string_lossy(),
|
let backup_path = "/opt/archipelago/web-ui.bak";
|
||||||
"-C",
|
|
||||||
"/opt/archipelago",
|
// All sudo calls that touch /opt/archipelago go through
|
||||||
"web-ui",
|
// host_sudo so they see a normal root mount namespace.
|
||||||
])
|
let mk = host_sudo(&["mkdir", "-p", &staging_new])
|
||||||
.status()
|
|
||||||
.await
|
|
||||||
.context("Failed to backup frontend")?;
|
|
||||||
if status.success() {
|
|
||||||
info!("Frontend backed up");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Extract new frontend into /opt/archipelago (root-owned dir).
|
|
||||||
let status = tokio::process::Command::new("sudo")
|
|
||||||
.args(["tar", "-xzf", &src.to_string_lossy(), "-C", "/opt/archipelago"])
|
|
||||||
.status()
|
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("Failed to extract {}", name))?;
|
.context("Failed to create frontend staging dir")?;
|
||||||
if !status.success() {
|
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);
|
anyhow::bail!("tar extraction failed for {}", name);
|
||||||
}
|
}
|
||||||
// nginx serves this tree; keep ownership consistent with
|
let _ = host_sudo(&[
|
||||||
// what first-boot + the ISO layout expect.
|
"chown",
|
||||||
let _ = tokio::process::Command::new("sudo")
|
"-R",
|
||||||
.args(["chown", "-R", "archipelago:archipelago", "/opt/archipelago/web-ui"])
|
"archipelago:archipelago",
|
||||||
.status()
|
&staging_new,
|
||||||
.await;
|
])
|
||||||
|
.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 rotate old web-ui")?;
|
||||||
|
if !mv_old.success() {
|
||||||
|
anyhow::bail!("failed to move old web-ui aside");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mv_new = host_sudo(&["mv", &staging_new, web_ui])
|
||||||
|
.await
|
||||||
|
.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");
|
info!(name = %name, "Frontend archive extracted to /opt/archipelago/web-ui");
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -370,10 +430,10 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
|||||||
// starting the new process — it would deadlock otherwise.
|
// starting the new process — it would deadlock otherwise.
|
||||||
tokio::spawn(async {
|
tokio::spawn(async {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
let _ = tokio::process::Command::new("sudo")
|
// systemctl talks to PID 1 over D-Bus — doesn't need the host
|
||||||
.args(["systemctl", "--no-block", "restart", "archipelago"])
|
// mount namespace, but routing through host_sudo keeps the
|
||||||
.status()
|
// apply flow's sudo calls uniform.
|
||||||
.await;
|
let _ = host_sudo(&["systemctl", "--no-block", "restart", "archipelago"]).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -317,26 +317,35 @@ const torConnected = computed(() => {
|
|||||||
})
|
})
|
||||||
const vpnStatus = ref({ connected: false, provider: '' })
|
const vpnStatus = ref({ connected: false, provider: '' })
|
||||||
const vpnConnected = computed(() => vpnStatus.value.connected || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
|
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 fipsDotClass = computed(() => {
|
||||||
const s = fipsStatus.value
|
const s = fipsStatus.value
|
||||||
if (!s || !s.installed) return 'bg-white/40'
|
if (!s || !s.installed) return 'bg-white/40'
|
||||||
if (s.service_active) return 'bg-green-400'
|
if (!s.service_active) return 'bg-white/40'
|
||||||
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 fipsTextClass = computed(() => {
|
||||||
const s = fipsStatus.value
|
const s = fipsStatus.value
|
||||||
if (!s || !s.installed) return 'text-white/40'
|
if (!s || !s.installed) return 'text-white/40'
|
||||||
if (s.service_active) return 'text-green-400'
|
if (!s.service_active) return 'text-white/40'
|
||||||
return 'text-white/40'
|
if (s.anchor_connected === false) return 'text-orange-400'
|
||||||
|
return 'text-green-400'
|
||||||
})
|
})
|
||||||
const fipsStatusLabel = computed(() => {
|
const fipsStatusLabel = computed(() => {
|
||||||
const s = fipsStatus.value
|
const s = fipsStatus.value
|
||||||
if (!s) return '…'
|
if (!s) return '…'
|
||||||
if (!s.installed) return 'Not installed'
|
if (!s.installed) return 'Not installed'
|
||||||
if (s.service_active) return 'Active'
|
if (!s.service_active) {
|
||||||
if (!s.key_present) return 'Awaiting seed'
|
if (!s.key_present) return 'Awaiting seed'
|
||||||
return 'Inactive'
|
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(() => {
|
const bitcoinSyncDisplay = computed(() => {
|
||||||
if (!systemStats.bitcoinAvailable) return 'Not running'
|
if (!systemStats.bitcoinAvailable) return 'Not running'
|
||||||
|
|||||||
@@ -420,25 +420,31 @@ const networkData = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// FIPS status row for the Local Network card. Full FIPS card lives below.
|
// 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 fipsRowLabel = computed(() => {
|
||||||
const s = fipsSummary.value
|
const s = fipsSummary.value
|
||||||
if (!s) return '…'
|
if (!s) return '…'
|
||||||
if (!s.installed) return 'Not installed'
|
if (!s.installed) return 'Not installed'
|
||||||
// Service-active wins even on legacy nodes with no seed-derived key.
|
if (!s.service_active) {
|
||||||
if (s.service_active) return 'Active'
|
if (!s.key_present) return 'Awaiting seed'
|
||||||
if (!s.key_present) return 'Awaiting seed'
|
return 'Inactive'
|
||||||
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 fipsRowTextClass = computed(() => {
|
||||||
const s = fipsSummary.value
|
const s = fipsSummary.value
|
||||||
if (!s || !s.installed) return 'text-white/40'
|
if (!s || !s.installed) return 'text-white/40'
|
||||||
if (s.service_active) return 'text-green-400'
|
if (!s.service_active) return 'text-white/60'
|
||||||
return 'text-white/60'
|
if (s.anchor_connected === false) return 'text-orange-400'
|
||||||
|
return 'text-green-400'
|
||||||
})
|
})
|
||||||
async function loadFipsSummary() {
|
async function loadFipsSummary() {
|
||||||
try {
|
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 */ }
|
} catch { /* backend too old */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -345,10 +345,12 @@ async function downloadUpdate() {
|
|||||||
downloadPercent.value = 0
|
downloadPercent.value = 0
|
||||||
statusMessage.value = ''
|
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(() => {
|
const progressInterval = setInterval(() => {
|
||||||
if (downloadPercent.value < 90) {
|
if (downloadPercent.value < 95) {
|
||||||
downloadPercent.value += Math.random() * 15
|
downloadPercent.value = Math.min(95, downloadPercent.value + Math.random() * 3)
|
||||||
}
|
}
|
||||||
}, 500)
|
}, 500)
|
||||||
|
|
||||||
|
|||||||
@@ -18,27 +18,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="p-3 bg-white/5 rounded-lg">
|
||||||
<p class="text-xs text-white/60 mb-1">Daemon version</p>
|
<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 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>
|
<p v-if="!status.installed" class="text-xs text-white/40 mt-1">Package not installed</p>
|
||||||
</div>
|
</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="p-3 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center justify-between gap-2 mb-1">
|
<div class="flex items-center justify-between gap-2 mb-1">
|
||||||
<p class="text-xs text-white/60">FIPS npub</p>
|
<p class="text-xs text-white/60">FIPS npub</p>
|
||||||
@@ -60,6 +45,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="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">
|
<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,
|
anchor_connected: false,
|
||||||
})
|
})
|
||||||
const installing = ref(false)
|
const installing = ref(false)
|
||||||
|
const reconnecting = ref(false)
|
||||||
const statusMessage = ref('')
|
const statusMessage = ref('')
|
||||||
const statusIsError = ref(false)
|
const statusIsError = ref(false)
|
||||||
const copied = 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)
|
onMounted(loadStatus)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -68,8 +68,13 @@
|
|||||||
>
|
>
|
||||||
<!-- Avatar -->
|
<!-- Avatar -->
|
||||||
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
|
<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'" />
|
<img
|
||||||
<div v-if="!identity.profile?.picture" class="w-full h-full flex items-center justify-center" :class="{
|
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-blue-500/20': identity.purpose === 'personal',
|
||||||
'bg-orange-500/20': identity.purpose === 'business',
|
'bg-orange-500/20': identity.purpose === 'business',
|
||||||
'bg-purple-500/20': identity.purpose === 'anonymous',
|
'bg-purple-500/20': identity.purpose === 'anonymous',
|
||||||
@@ -88,7 +93,8 @@
|
|||||||
<!-- Info -->
|
<!-- Info -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<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">★</span>
|
<span v-if="identity.is_default" class="text-yellow-400 text-xs" title="Default identity">★</span>
|
||||||
<span class="text-xs px-2 py-0.5 rounded-full capitalize" :class="{
|
<span class="text-xs px-2 py-0.5 rounded-full capitalize" :class="{
|
||||||
'bg-blue-500/20 text-blue-300': identity.purpose === 'personal',
|
'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="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="flex items-center gap-3 mb-5">
|
||||||
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
|
<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'" />
|
<img
|
||||||
<div v-else class="w-full h-full flex items-center justify-center">
|
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>
|
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -367,7 +379,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { reactive, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { safeClipboardWrite } from './utils'
|
import { safeClipboardWrite } from './utils'
|
||||||
@@ -408,10 +420,38 @@ const profilePublishing = ref(false)
|
|||||||
const avatarUploading = ref(false)
|
const avatarUploading = ref(false)
|
||||||
const bannerUploading = ref(false)
|
const bannerUploading = ref(false)
|
||||||
|
|
||||||
// Upload to the node's blob store and drop the returned public URL into
|
// Track image load failures so the UI can fall back to the initial/
|
||||||
// the profile field. The /api/blob endpoint marks these blobs public, so
|
// identicon placeholder instead of showing a blank square. Pasted URLs
|
||||||
// the URL served back (`public_url`, onion-rooted when Tor is up) is
|
// that 404 (or point at an onion the local browser can't reach) were
|
||||||
// reachable by external Nostr clients fetching kind:0 metadata.
|
// 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') {
|
async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
||||||
const input = ev.target as HTMLInputElement
|
const input = ev.target as HTMLInputElement
|
||||||
const file = input?.files?.[0]
|
const file = input?.files?.[0]
|
||||||
@@ -421,6 +461,14 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
|||||||
profileError.value = ''
|
profileError.value = ''
|
||||||
try {
|
try {
|
||||||
const buf = await file.arrayBuffer()
|
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', {
|
const resp = await fetch('/api/blob', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -436,6 +484,11 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
|||||||
const url = public_url || self_test_url
|
const url = public_url || self_test_url
|
||||||
if (!url) throw new Error('blob API returned no URL')
|
if (!url) throw new Error('blob API returned no URL')
|
||||||
profileForm.value[field] = 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) {
|
} catch (e: unknown) {
|
||||||
profileError.value = e instanceof Error ? e.message : `${field} upload failed`
|
profileError.value = e instanceof Error ? e.message : `${field} upload failed`
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user