merge: bring main (v1.7.125 + .126 work) into phase-13 branch pre-deploy
63 main commits since the fork point — gate cookie-strip fix, named-volume create fix, appgate catalog classification, RNode error surfacing — merged so 13-14/13-15 on-device verification runs against current production code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.120-alpha",
|
||||
"version": "1.7.125-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.120-alpha",
|
||||
"version": "1.7.125-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.120-alpha",
|
||||
"version": "1.7.125-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -442,7 +442,7 @@
|
||||
"author": "Portainer",
|
||||
"category": "development",
|
||||
"tier": "optional",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
|
||||
"repoUrl": "https://github.com/portainer/portainer",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
|
||||
@@ -0,0 +1,927 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Archipelago — Seed & Entropy</title>
|
||||
<link rel="icon" href="/favicon-v2.ico">
|
||||
<style>
|
||||
/* Everything below is lifted from the app's own stylesheets
|
||||
(src/style.css + views/dashboard/dashboard-styles.css) so this page is
|
||||
the dashboard, not a lookalike. No new container styles: .glass-card is
|
||||
the only box. */
|
||||
|
||||
:root { color-scheme: dark; }
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-Bold.ttf') format('truetype');
|
||||
font-weight: 700; font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-ExtraBold.ttf') format('truetype');
|
||||
font-weight: 800; font-style: normal;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: 'Avenir Next', system-ui, -apple-system, sans-serif;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1.7;
|
||||
font-size: 16px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Dashboard background layer — the Settings wallpaper, as the app uses it */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed; inset: 0; z-index: -2;
|
||||
background: #000 url('/assets/img/bg-settings.webp') center center / cover no-repeat;
|
||||
}
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed; inset: 0; z-index: -1;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.45), rgba(0,0,0,0.62));
|
||||
}
|
||||
|
||||
.dashboard-view { display: flex; min-height: 100vh; }
|
||||
|
||||
/* ---- Sidebar (dashboard-styles.css) ---- */
|
||||
aside {
|
||||
width: 256px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
z-index: 10;
|
||||
}
|
||||
.sidebar-shell {
|
||||
width: 100%; height: 100%; min-height: 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-inner { display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden; }
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
margin-bottom: 2rem; padding: 1.5rem 1.5rem 0; flex-shrink: 0;
|
||||
}
|
||||
.sidebar-logo h2 {
|
||||
font-size: 1.125rem; font-weight: 600; color: #fff;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.sidebar-logo p { font-size: 0.75rem; color: rgba(255, 255, 255, 0.6); }
|
||||
|
||||
/* AnimatedLogo.vue — gradient ring + staggered square reveal */
|
||||
.logo-gradient-border {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
width: 3.5rem; height: 3.5rem;
|
||||
border-radius: 9999px;
|
||||
padding: 3px;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.6) 0%, rgba(0,0,0,0.8) 100%);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.logo-gradient-border::after {
|
||||
content: '';
|
||||
position: absolute; inset: 3px;
|
||||
border-radius: 9999px;
|
||||
background: #000;
|
||||
z-index: 0;
|
||||
}
|
||||
.logo-gradient-border svg {
|
||||
border-radius: 9999px;
|
||||
display: block; position: relative; z-index: 1;
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
.logo-square {
|
||||
opacity: 0;
|
||||
animation: logo-square-in 3s ease-out infinite;
|
||||
animation-delay: var(--delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
@keyframes logo-square-in {
|
||||
0% { opacity: 0; }
|
||||
15% { opacity: 1; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1; min-height: 0;
|
||||
overflow-y: auto; overscroll-behavior: contain;
|
||||
padding: 1rem 1.5rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
|
||||
}
|
||||
.sidebar-nav::-webkit-scrollbar { width: 6px; }
|
||||
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.22); border-radius: 999px; }
|
||||
.sidebar-nav > * + * { margin-top: 0.5rem; }
|
||||
|
||||
.sidebar-nav-item {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-decoration: none;
|
||||
font-size: 0.9375rem;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
.sidebar-nav-item:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||
.sidebar-nav-item svg { width: 1.25rem; height: 1.25rem; flex-shrink: 0; }
|
||||
|
||||
/* nav-tab-active (style.css) — the app's current-section treatment */
|
||||
.nav-tab-active {
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.25);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-tab-active::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.3), transparent);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-bottom {
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.18), transparent 100%);
|
||||
}
|
||||
|
||||
/* Entrance animation, mirroring the dashboard's staggered sidebar reveal */
|
||||
.sidebar-logo { opacity: 0; animation: sidebar-logo-in 0.5s cubic-bezier(0.25,0.46,0.45,0.94) 0.05s forwards; }
|
||||
@keyframes sidebar-logo-in {
|
||||
0% { opacity: 0; transform: translateY(-8px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.sidebar-nav-item {
|
||||
opacity: 0;
|
||||
animation: sidebar-nav-item-in 0.4s cubic-bezier(0.25,0.46,0.45,0.94) forwards;
|
||||
animation-delay: calc(0.22s + var(--nav-stagger, 0) * 0.06s);
|
||||
}
|
||||
@keyframes sidebar-nav-item-in {
|
||||
0% { opacity: 0; transform: translateX(-12px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* ---- Main content ---- */
|
||||
main {
|
||||
flex: 1; min-width: 0;
|
||||
padding: 2.5rem 2rem 6rem;
|
||||
}
|
||||
.content { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
h1 {
|
||||
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||
font-size: 2rem; font-weight: 800;
|
||||
color: #fff;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
h2 {
|
||||
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||
font-size: 1.5rem; font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 3rem 0 0.25rem;
|
||||
scroll-margin-top: 1.5rem;
|
||||
}
|
||||
h3 { font-size: 1.125rem; font-weight: 600; color: #fff; margin: 1.75rem 0 0.5rem; }
|
||||
h4 { font-size: 0.9375rem; font-weight: 600; color: #fff; margin: 0 0 0.35rem; }
|
||||
p { margin: 0.5rem 0 1rem; }
|
||||
.glass-card > p:last-child, .glass-card > ul:last-child { margin-bottom: 0; }
|
||||
ul, ol { margin: 0.5rem 0 1rem 1.25rem; }
|
||||
li { margin: 0.25rem 0; }
|
||||
.lede { color: rgba(255,255,255,0.6); font-size: 0.9375rem; margin-bottom: 1rem; }
|
||||
a { color: #fb923c; }
|
||||
|
||||
/* The section label the app uses above grouped content */
|
||||
.section-label {
|
||||
display: block;
|
||||
font-size: 0.6875rem; font-weight: 700;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.section-label.accent { color: #fb923c; }
|
||||
|
||||
/* glass-card (style.css) — the ONLY container on this page */
|
||||
.glass-card {
|
||||
background-color: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
border-radius: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin: 1rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Layout only — no new box chrome */
|
||||
.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); margin: 1rem 0; }
|
||||
.grid .glass-card { margin: 0; }
|
||||
|
||||
/* Orange count badge, as used on sidebar nav items */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 1.25rem; height: 1.25rem; padding: 0 0.4rem;
|
||||
border-radius: 9999px;
|
||||
background: #f97316; color: #fff;
|
||||
font-size: 10px; font-weight: 700;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.badge.muted { background: rgba(255,255,255,0.14); color: rgba(255,255,255,0.85); }
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.pills { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
|
||||
th {
|
||||
text-align: left; padding: 0.5rem 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.6875rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
|
||||
white-space: nowrap;
|
||||
}
|
||||
td { padding: 0.625rem 0.75rem; border-bottom: 1px solid rgba(255, 255, 255, 0.06); vertical-align: top; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
code {
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.8125rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
color: #fb923c;
|
||||
}
|
||||
pre {
|
||||
font-family: 'Menlo', 'Monaco', monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
overflow-x: auto;
|
||||
}
|
||||
pre code { background: none; padding: 0; color: rgba(255,255,255,0.85); }
|
||||
pre .a { color: #fb923c; font-weight: 600; }
|
||||
pre .g { color: #4ade80; }
|
||||
pre .b { color: #60a5fa; }
|
||||
pre .r { color: #f87171; }
|
||||
pre .p { color: #a78bfa; }
|
||||
pre .y { color: #facc15; }
|
||||
|
||||
.ok { color: #4ade80; }
|
||||
.warn { color: #facc15; }
|
||||
.bad { color: #f87171; }
|
||||
|
||||
ol.steps { list-style: none; margin-left: 0; counter-reset: s; }
|
||||
ol.steps li {
|
||||
counter-increment: s;
|
||||
position: relative;
|
||||
padding-left: 2.25rem;
|
||||
margin: 0.85rem 0;
|
||||
}
|
||||
ol.steps li::before {
|
||||
content: counter(s);
|
||||
position: absolute; left: 0; top: 0.15rem;
|
||||
width: 1.5rem; height: 1.5rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(249, 115, 22, 0.18);
|
||||
color: #fb923c;
|
||||
font-size: 0.75rem; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
aside { display: none; }
|
||||
main { padding: 1.5rem 1rem 4rem; }
|
||||
h1 { font-size: 1.5rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="dashboard-view">
|
||||
|
||||
<aside>
|
||||
<div class="sidebar-shell">
|
||||
<div class="sidebar-inner">
|
||||
|
||||
<div class="sidebar-logo">
|
||||
<div class="logo-gradient-border">
|
||||
<svg viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="Neode">
|
||||
<rect width="1024" height="1024" fill="#030202"/>
|
||||
<rect class="logo-square" style="--delay:0ms" x="357.614" y="318" width="71.007" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:100ms" x="436.152" y="318" width="72.082" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:200ms" x="515.766" y="318" width="72.082" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:300ms" x="595.379" y="318" width="71.007" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:400ms" x="595.379" y="396.46" width="71.007" height="72.011" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:500ms" x="673.917" y="396.46" width="72.083" height="72.011" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:600ms" x="278" y="475.994" width="72.083" height="72.012" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:700ms" x="357.614" y="475.994" width="71.007" height="72.012" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:800ms" x="436.152" y="475.994" width="72.082" height="72.012" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:900ms" x="515.766" y="475.994" width="72.082" height="72.012" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1000ms" x="595.379" y="475.994" width="71.007" height="72.012" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1100ms" x="673.917" y="475.994" width="72.083" height="72.012" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1200ms" x="278" y="555.529" width="72.083" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1300ms" x="357.614" y="555.529" width="71.007" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1400ms" x="595.379" y="555.529" width="71.007" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1500ms" x="673.917" y="555.529" width="72.083" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1600ms" x="357.614" y="633.989" width="71.007" height="72.011" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1700ms" x="436.152" y="633.989" width="72.082" height="72.011" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1800ms" x="515.766" y="633.989" width="72.082" height="72.011" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1900ms" x="595.379" y="633.989" width="71.007" height="72.011" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="min-width:0;flex:1">
|
||||
<h2>Seed & Entropy</h2>
|
||||
<p>Node security guide</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav" aria-label="Guide sections">
|
||||
<a class="sidebar-nav-item nav-tab-active" href="#overview" style="--nav-stagger:0">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
|
||||
<span>Overview</span>
|
||||
</a>
|
||||
<a class="sidebar-nav-item" href="#creation" style="--nav-stagger:1">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span>How it's created</span>
|
||||
</a>
|
||||
<a class="sidebar-nav-item" href="#guardrails" style="--nav-stagger:2">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
|
||||
<span>Guardrails</span>
|
||||
<span class="badge">5</span>
|
||||
</a>
|
||||
<a class="sidebar-nav-item" href="#storage" style="--nav-stagger:3">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
|
||||
<span>Stored on disk</span>
|
||||
</a>
|
||||
<a class="sidebar-nav-item" href="#derivation" style="--nav-stagger:4">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/></svg>
|
||||
<span>Derivation tree</span>
|
||||
</a>
|
||||
<a class="sidebar-nav-item" href="#failures" style="--nav-stagger:5">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
<span>Failures</span>
|
||||
</a>
|
||||
<a class="sidebar-nav-item" href="#restore" style="--nav-stagger:6">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
<span>Restore</span>
|
||||
</a>
|
||||
<a class="sidebar-nav-item" href="#verify" style="--nav-stagger:7">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span>Verify it yourself</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-bottom">
|
||||
<a class="sidebar-nav-item" href="/dashboard/settings" style="--nav-stagger:8">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
|
||||
<span>Back to Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="content">
|
||||
|
||||
<h1>Your node's seed & entropy</h1>
|
||||
<p class="lede">How 32 random bytes become every key this node owns — where the randomness comes from, what protects it, and exactly what your 24 words can and cannot bring back.</p>
|
||||
<div class="pills">
|
||||
<span class="pill">256-bit entropy</span>
|
||||
<span class="pill">BIP-39 · 24 words</span>
|
||||
<span class="pill">HKDF-SHA256</span>
|
||||
<span class="pill">Kernel CSPRNG only</span>
|
||||
<span class="pill">KEY-05 hardened</span>
|
||||
</div>
|
||||
|
||||
<h2 id="overview">Overview</h2>
|
||||
<p class="lede">One master secret, many keys — by design.</p>
|
||||
|
||||
<p>
|
||||
Almost everything cryptographic on this node — its identity, its Nostr keys, its mesh
|
||||
transport keys, its Lightning wallet — grows from a <strong>single master seed</strong>:
|
||||
32 bytes of randomness drawn once, shown to you once as a 24-word recovery phrase, and
|
||||
never stored in raw form anywhere.
|
||||
</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<span class="section-label accent">In plain words</span>
|
||||
<p>
|
||||
Think of the seed as an acorn. Every branch of the tree — your identity, your wallet,
|
||||
your mesh radio's name — grows from it in a fixed, repeatable pattern. Plant the same
|
||||
acorn on new hardware by typing your 24 words and the <em>same tree</em> grows back,
|
||||
branch for branch. That is why those words are the most valuable thing your node ever
|
||||
shows you, and why anyone who copies them owns your tree.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<pre><span class="b">Linux kernel CSPRNG</span> (interrupt timing, jitter, CPU RNG)
|
||||
│
|
||||
│ getrandom(2) — via an explicitly named <span class="a">OsRng</span>, nothing else allowed
|
||||
▼
|
||||
<span class="a">32 bytes raw entropy</span> ──▶ degenerate-draw check ──▶ <span class="r">refuse & wipe if suspicious</span>
|
||||
│
|
||||
│ BIP-39 encoding
|
||||
▼
|
||||
<span class="g">24-word recovery phrase</span> ←── the only form you ever see or back up
|
||||
│
|
||||
│ PBKDF2-HMAC-SHA512 × 2048
|
||||
▼
|
||||
<span class="a">64-byte master seed</span> ←── lives only in RAM, never written to disk
|
||||
│
|
||||
├─ HKDF "archipelago/node/ed25519/v1" ──▶ <span class="g">Node identity key + DID</span>
|
||||
├─ HKDF "archipelago/nostr-node/…/v1" ──▶ <span class="p">Node Nostr key (npub)</span>
|
||||
├─ HKDF "archipelago/fips/secp256k1/v1" ──▶ <span class="b">FIPS mesh transport key</span>
|
||||
├─ HKDF "archipelago/identity/{i}/…/v1" ──▶ <span class="g">Personal identities</span>
|
||||
├─ BIP-32 m/44'/1237'/0'/0/{i} (NIP-06) ──▶ <span class="p">Personal Nostr keys</span>
|
||||
├─ HKDF "archipelago/lnd/entropy/v1" ──▶ <span class="y">Lightning entropy → aezeed</span>
|
||||
└─ BIP-32 m/84'/0'/0' ──▶ <span class="y">Bitcoin xprv (dormant)</span>
|
||||
│
|
||||
│ and from the node key, second-order:
|
||||
▼
|
||||
├─ <span class="b">Reticulum / LXMF mesh identity</span>
|
||||
├─ <span class="g">Message-store + contacts encryption</span>
|
||||
└─ <span class="g">Credential-store key</span>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<h4>One rule to remember</h4>
|
||||
<p>
|
||||
If it is on the diagram above, your 24 words rebuild it from scratch, on any hardware,
|
||||
forever. If it is not on the diagram — session tokens, app passwords, WireGuard keys,
|
||||
Lightning channel state — it is independent randomness, protected by other backups.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 id="creation">How it's created</h2>
|
||||
<p class="lede">One named source. No mixing. No silent defaults.</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<span class="section-label accent">In plain words</span>
|
||||
<p>
|
||||
Computers cannot invent randomness — they collect it. The Linux kernel constantly
|
||||
harvests unpredictable physical noise (the exact nanosecond a network card interrupts,
|
||||
timing jitter between CPU cores, the CPU's hardware random generator) into a
|
||||
cryptographic pool. Archipelago rolls its dice by asking that pool directly, and only
|
||||
that pool. There is deliberately no blending of other sources: a single, named,
|
||||
well-studied source is auditable, whereas a blend is a place for bugs to hide.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>Technically</h3>
|
||||
<p>
|
||||
The master seed is generated by <code>MasterSeed::generate()</code> in
|
||||
<code>core/archipelago/src/seed.rs</code>. It fills a 32-byte buffer using
|
||||
<code>rand::rngs::OsRng</code> — a thin wrapper around the <code>getrandom(2)</code>
|
||||
system call, which reads the kernel CSPRNG (same source as <code>/dev/urandom</code>,
|
||||
but immune to file-descriptor exhaustion and chroot tricks).
|
||||
</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<pre><code>let mut entropy = [0u8; 32];
|
||||
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut entropy)?; // guarded draw
|
||||
let mnemonic = bip39::Mnemonic::from_entropy(&entropy)?; // → 24 words
|
||||
entropy.zeroize(); // wipe raw bytes</code></pre>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
<li><strong>Exactly 32 bytes / 256 bits</strong> — the maximum BIP-39 strength, encoding to 24 words.</li>
|
||||
<li><strong>The RNG is named at the call site.</strong> No function anywhere generates key material with a default or implicit RNG.</li>
|
||||
<li><strong>The RNG type is compiler-enforced.</strong> Key generation only accepts RNGs on a sealed allowlist (<code>KeyGenRng</code>) whose single production member is <code>OsRng</code>.</li>
|
||||
<li><strong>The buffer is zeroized</strong> on every path, success or failure.</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
The words are then stretched into the 64-byte master seed by standard BIP-39:
|
||||
PBKDF2-HMAC-SHA512, 2048 rounds, empty passphrase. That 64-byte value is a 512-bit
|
||||
expansion of the same 256 bits of entropy — not extra randomness. It exists only in
|
||||
memory, is recomputed from the words when needed, and never touches disk.
|
||||
</p>
|
||||
|
||||
<h3>When the seed is born</h3>
|
||||
<p>At onboarding — not at first boot.</p>
|
||||
<div class="glass-card">
|
||||
<ol class="steps">
|
||||
<li><strong>First boot: a placeholder.</strong> A freshly flashed node boots with a random <em>temporary</em> identity key so services can start. It is not seed-derived and is about to be thrown away.</li>
|
||||
<li><strong>Onboarding: the real draw.</strong> At the "Recovery phrase" step, the <code>seed.generate</code> RPC performs the guarded 32-byte draw and shows you the 24 words.</li>
|
||||
<li><strong>Derivation.</strong> Node key, DID, Nostr key, FIPS mesh key and your first identity are derived and written to <code>/var/lib/archipelago/identity/</code> at mode 0600, overwriting the placeholder.</li>
|
||||
<li><strong>Password setup: the backup is sealed.</strong> The words are encrypted under your login password and stored as <code>master_seed.enc</code>, so you can reveal them again later.</li>
|
||||
</ol>
|
||||
<p>
|
||||
Generation is idempotent for 10 minutes and serialised behind a lock: a browser refresh
|
||||
returns the <em>same</em> words rather than minting a second seed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 id="guardrails">Guardrails</h2>
|
||||
<p class="lede">Defence in depth around a single random draw.</p>
|
||||
|
||||
<div class="grid">
|
||||
<div class="glass-card">
|
||||
<h4><span class="badge muted">1</span> Sealed RNG allowlist</h4>
|
||||
<p>Key draws only compile against RNG types on a closed, private allowlist. A refactor that swaps in a weak or deterministic RNG becomes a <em>compile error</em>, not a silent disaster.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4><span class="badge muted">2</span> Degenerate-draw refusal</h4>
|
||||
<p>Every draw is checked for three broken-RNG shapes: all zeros, all bytes identical, or a counting pattern. A match is refused and wiped — <strong>never retried</strong>, because retrying would mask a broken RNG instead of exposing it.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4><span class="badge muted">3</span> CSPRNG readiness ledger</h4>
|
||||
<p>Before generating, the node probes whether the kernel pool is fully initialised and appends the verdict to an append-only log at <code>security/csprng-readiness.jsonl</code> (0600). You can audit the entropy conditions your seed was born under, forever.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4><span class="badge muted">4</span> Build-time lint bans</h4>
|
||||
<p>CI bans <code>rand::random()</code> and <code>rand::thread_rng()</code> across the workspace — the two convenient entry points behind real-world wallet disasters. Using either fails the build.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4><span class="badge muted">5</span> Zeroization everywhere</h4>
|
||||
<p>Raw entropy, mnemonics and derived secrets are wiped from memory on every code path, including error paths, so key material does not linger in freed RAM or crash dumps.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<span class="section-label accent">In plain words</span>
|
||||
<h4>Why so paranoid about one function?</h4>
|
||||
<p>
|
||||
In 2026 a well-known hardware wallet shipped a bug where a refactor quietly switched
|
||||
seed generation to a <em>predictable</em> random source — no error, no warning, and seeds
|
||||
that looked perfectly normal. Predictable randomness is invisible: the words look random,
|
||||
the wallet works, and months later someone who can predict the generator drains it.
|
||||
Archipelago's answer is to make that entire class of bug impossible to compile, and to
|
||||
log the health of the random pool at the moment your seed was created.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>What the degenerate check does and doesn't do</h3>
|
||||
<p>
|
||||
It is deliberately closed-form: it recognises exactly three catastrophic shapes
|
||||
(all-zero, all-identical, ±1 counter). It is <em>not</em> a statistical entropy estimator —
|
||||
those cannot distinguish good randomness from a cleverly broken RNG and add false
|
||||
positives. The security load is carried by guardrails 1, 3 and 4; this is a tripwire for
|
||||
total RNG failure, such as a buffer that was never filled.
|
||||
</p>
|
||||
|
||||
<h3>Recent hardening</h3>
|
||||
<p>
|
||||
This system was audited and rebuilt in early August 2026. The headline finding: the
|
||||
mnemonic library was silently choosing its own RNG via a transitive default. It happened
|
||||
to be a secure one, but nothing guaranteed that, and a dependency update could have
|
||||
changed it with no diff in Archipelago's own code.
|
||||
</p>
|
||||
<div class="glass-card">
|
||||
<table>
|
||||
<tr><th>Date</th><th>Change</th></tr>
|
||||
<tr><td>Jul 30</td><td>Kernel CSPRNG readiness probe; non-determinism regression test (64 consecutive mnemonics must be unique).</td></tr>
|
||||
<tr><td>Jul 31</td><td>Full entropy audit published (findings F-01…F-13).</td></tr>
|
||||
<tr><td>Aug 1</td><td><strong>The pivotal fix:</strong> master-seed RNG made explicit — <code>OsRng</code> named at the call site, injected through a testable seam, pinned by a known-answer test.</td></tr>
|
||||
<tr><td>Aug 2</td><td>Audit widened: 43 defaulted-RNG call sites across 15 files migrated to explicit <code>OsRng</code>, including AEAD nonces and ecash key material.</td></tr>
|
||||
<tr><td>Aug 2</td><td>Onboarding RPCs gated — <code>seed.restore</code> now refuses on a provisioned node (previously an unauthenticated restore could hijack a live node; fixed before any release shipped it).</td></tr>
|
||||
<tr><td>Aug 2</td><td>KEY-05 layer landed: sealed allowlist, guarded draws, readiness ledger, clippy bans, supply-chain pinning of the <code>rand</code> crate.</td></tr>
|
||||
<tr><td>Aug 2</td><td>Legacy Bitcoin Core wallet-import path deleted — the master xprv is no longer handed to any external wallet process.</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 id="storage">Stored on disk</h2>
|
||||
<p class="lede">The words, encrypted — and the derived keys. Never the raw seed.</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<table>
|
||||
<tr><th>File</th><th>Contents</th><th>Protection</th></tr>
|
||||
<tr><td><code>identity/master_seed.enc</code></td><td>Your 24 words, encrypted</td><td>Argon2(login password) + ChaCha20-Poly1305, 0600</td></tr>
|
||||
<tr><td><code>identity/node_key</code></td><td>Node Ed25519 identity key</td><td>0600, seed-derived</td></tr>
|
||||
<tr><td><code>identity/nostr_secret</code></td><td>Node Nostr keypair</td><td>0600, seed-derived</td></tr>
|
||||
<tr><td><code>identity/fips_key</code></td><td>FIPS mesh transport key (bech32 nsec)</td><td>0600, seed-derived</td></tr>
|
||||
<tr><td><code>identity/identity_index</code></td><td>Next unused derivation index</td><td>Plain integer, not secret</td></tr>
|
||||
<tr><td><code>identities/<uuid>.json</code></td><td>Identity records: keys + metadata</td><td>0600; keys seed-derived, <em>metadata is not</em></td></tr>
|
||||
<tr><td><code>identity/lnd_aezeed.enc</code></td><td>Lightning wallet's own seed</td><td>Encrypted under the LND wallet password</td></tr>
|
||||
<tr><td><code>security/csprng-readiness.jsonl</code></td><td>Append-only entropy audit trail</td><td>0600; outside <code>identity/</code> so restores never touch it</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<h4 class="ok">The raw seed never touches disk</h4>
|
||||
<p>
|
||||
What is stored is the <em>encrypted words</em> and the <em>derived keys</em>. The 64-byte
|
||||
master seed is recomputed in RAM from the words when needed and wiped afterwards.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>The encrypted envelope</h3>
|
||||
<div class="glass-card">
|
||||
<pre>login password ──▶ <span class="a">Argon2id</span> (memory-hard) ──▶ 256-bit file key
|
||||
▲
|
||||
16-byte random salt
|
||||
|
||||
24 words ──▶ <span class="a">ChaCha20-Poly1305</span> (authenticated, 12-byte random nonce)
|
||||
│
|
||||
▼
|
||||
┌───────────┬────────────┬───────────────────────────┐
|
||||
│ salt (16) │ nonce (12) │ ciphertext + auth tag │ = master_seed.enc
|
||||
└───────────┴────────────┴───────────────────────────┘
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<span class="section-label accent">In plain words</span>
|
||||
<p>
|
||||
Your words are locked in a digital safe whose combination is your login password, run
|
||||
through a deliberately slow, memory-hungry grinder (Argon2) so guessing billions of
|
||||
passwords per second is impractical even for someone who steals the file. The
|
||||
authentication tag means the safe also notices tampering: a modified file fails loudly
|
||||
rather than yielding wrong words.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Revealing the words later (Settings → Backup → Reveal) requires an authenticated session,
|
||||
re-entering your password, and your 2FA code if enabled. It is rate-limited, and the words
|
||||
go only to your browser — never to logs.
|
||||
</p>
|
||||
|
||||
<h2 id="derivation">Derivation tree</h2>
|
||||
<p class="lede">Every key, its exact derivation, and where it lands.</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<span class="section-label accent">In plain words</span>
|
||||
<p>
|
||||
The node never uses the master seed directly as a key. It uses HKDF — think of a
|
||||
locksmith who, given one master blank and a <em>label</em> ("node key", "mesh key",
|
||||
"Lightning entropy"), cuts a completely different, unrelated key for each label. Knowing
|
||||
one cut key tells you nothing about the others or about the blank. The labels are fixed
|
||||
strings baked into the code, which is what lets the identical tree regrow on new hardware.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<table>
|
||||
<tr><th>Key</th><th>Method</th><th>Label / path</th></tr>
|
||||
<tr><td><strong>Node identity (Ed25519)</strong> — signs everything, forms your DID</td><td>HKDF-SHA256</td><td><code>archipelago/node/ed25519/v1</code></td></tr>
|
||||
<tr><td><strong>Node Nostr key</strong> — the node's npub</td><td>HKDF-SHA256</td><td><code>archipelago/nostr-node/secp256k1/v1</code></td></tr>
|
||||
<tr><td><strong>FIPS mesh transport key</strong></td><td>HKDF-SHA256</td><td><code>archipelago/fips/secp256k1/v1</code></td></tr>
|
||||
<tr><td><strong>Personal identity #i (Ed25519)</strong></td><td>HKDF-SHA256</td><td><code>archipelago/identity/{i}/ed25519/v1</code></td></tr>
|
||||
<tr><td><strong>Personal Nostr key #i</strong> — NIP-06 standard, portable to other Nostr apps</td><td>BIP-32</td><td><code>m/44'/1237'/0'/0/{i}</code></td></tr>
|
||||
<tr><td><strong>Lightning wallet entropy</strong> — 16 bytes</td><td>HKDF-SHA256</td><td><code>archipelago/lnd/entropy/v1</code></td></tr>
|
||||
<tr><td><strong>Bitcoin BIP-84 xprv</strong> — dormant, reserved for a future cold vault</td><td>BIP-32</td><td><code>m/84'/0'/0'</code></td></tr>
|
||||
<tr><td><strong>Release-root signing key</strong> — never on a node; derived offline by the publisher</td><td>HKDF-SHA256</td><td><code>archipelago/release/root/ed25519/v1</code></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
All HKDF derivations are HKDF-SHA256 with a distinct, versioned label — the <code>/v1</code>
|
||||
suffix means a future migration can introduce <code>/v2</code> without ambiguity. Personal
|
||||
Nostr keys deliberately use the NIP-06 standard path instead of HKDF, so the same 24 words
|
||||
typed into any NIP-06 Nostr client reproduce the same npub: your social identity is portable
|
||||
beyond Archipelago.
|
||||
</p>
|
||||
|
||||
<h3>The Lightning special case</h3>
|
||||
<div class="glass-card">
|
||||
<pre>master seed ──HKDF──▶ 16 bytes ──▶ <span class="y">LND generates its own "aezeed"</span> ──▶ wallet
|
||||
│
|
||||
│ ⚠ one-way: the aezeed cannot be
|
||||
│ recomputed from your 24 words
|
||||
▼
|
||||
captured ONCE at init, stored encrypted as
|
||||
<span class="a">identity/lnd_aezeed.enc</span>
|
||||
</pre>
|
||||
</div>
|
||||
<p>
|
||||
LND uses its own seed format, <em>aezeed</em>, which is not BIP-39. Archipelago derives
|
||||
deterministic entropy from your master seed and hands it to LND at wallet creation — but LND
|
||||
wraps it with its own internal salt, so the resulting aezeed cannot be re-derived from your
|
||||
24 words afterwards. The node captures it once and stores it encrypted alongside your other
|
||||
identity files.
|
||||
</p>
|
||||
<div class="glass-card">
|
||||
<h4 class="warn">Back up the Lightning seed separately</h4>
|
||||
<p>
|
||||
Your 24 words restore your node identity and on-chain derivations, but <em>not</em> an
|
||||
already-initialised Lightning wallet, and never off-chain channel balances (those need
|
||||
channel backups, as Lightning requires by design). Treat the aezeed in the Lightning
|
||||
backup screen as a second phrase worth writing down. It restores into LND-based wallets
|
||||
such as Zeus, Blixt or another Archipelago node — hardware wallets cannot import it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>Second-order keys</h3>
|
||||
<p>
|
||||
Some subsystems derive from the <em>node identity key</em> rather than the master seed
|
||||
directly. Since the node key is itself seed-derived, these still regrow from your words:
|
||||
<code>words → master seed → node key → subsystem key</code>. Each prefixes a unique fixed
|
||||
string before hashing (domain separation), so compromising one never exposes another.
|
||||
</p>
|
||||
<div class="glass-card">
|
||||
<table>
|
||||
<tr><th>Subsystem</th><th>Derivation from <code>node_key</code></th></tr>
|
||||
<tr><td><strong>Reticulum / LXMF mesh identity</strong> (LoRa long-range mesh)</td><td>HKDF-SHA256, salt <code>archipelago-reticulum-identity-v1</code>, separate X25519 + Ed25519 labels — a stable address that survives reinstalls</td></tr>
|
||||
<tr><td><strong>Message store</strong> (chats at rest)</td><td><code>SHA-256("archipelago-message-store-v1" ‖ node_key)</code></td></tr>
|
||||
<tr><td><strong>Mesh contacts</strong></td><td><code>SHA-256("archipelago-mesh-contacts-v1" ‖ node_key)</code></td></tr>
|
||||
<tr><td><strong>Credential store</strong> (saved app credentials)</td><td>Same domain-separated SHA-256 pattern</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>What is <em>not</em> derived from the seed</h3>
|
||||
<p>
|
||||
Plenty of secrets are freshly random instead. That is intentional: things that should die
|
||||
with a session, rotate freely, or belong to a third-party app must not be recoverable from
|
||||
your words.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<div class="glass-card">
|
||||
<h4>Ephemeral by design</h4>
|
||||
<p>Session tokens, device pairing tokens, federation invites, TOTP secrets and backup codes, all encryption nonces, X3DH ephemeral mesh keys, anonymous marketplace and discovery Nostr keys.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4>App-owned secrets</h4>
|
||||
<p>Every manifest-declared <code>generated_secret</code> (app database passwords, API keys), Bitcoin RPC credentials, the LND wallet <em>password</em> (distinct from its seed), Home Assistant tokens.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4>Host-level material</h4>
|
||||
<p>WireGuard keypairs (via <code>wg genkey</code>), SSH host keys and the TLS certificate (created by the installer image at first boot), the machine-id.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4>Opt-outs from derivability</h4>
|
||||
<p>Identities created with "new random key" instead of seed derivation, and a node key after an explicit <code>rotate-key</code> — rotation deliberately breaks the link to your words, and says so.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 id="failures">Failures</h2>
|
||||
<p class="lede">What happens when something goes wrong, at every stage.</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<table>
|
||||
<tr><th>Scenario</th><th>Behaviour</th><th>Outcome</th></tr>
|
||||
<tr>
|
||||
<td>RNG returns a degenerate pattern</td>
|
||||
<td>Draw refused and wiped, <strong>never retried</strong>; error logged; onboarding fails loudly</td>
|
||||
<td class="bad">No seed created</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Kernel pool not yet initialised</td>
|
||||
<td><code>getrandom(2)</code> blocks until seeded — an unseeded pool cannot produce a seed. The probe logs a warning and records the verdict</td>
|
||||
<td class="warn">Waits, then proceeds</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Onboarding page refreshed mid-generation</td>
|
||||
<td>Same words returned for 10 minutes, mutex-serialised; no second seed can be minted</td>
|
||||
<td class="ok">Idempotent</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>master_seed.enc</code> missing</td>
|
||||
<td>Node runs normally — derived keys are already on disk. Only Reveal and future re-derivation are unavailable, and the UI says so</td>
|
||||
<td class="warn">Degraded, functional</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Seed file corrupt, or wrong password</td>
|
||||
<td>Authenticated decryption fails closed with an explicit error — no fallback, no partial output, no auto-regeneration</td>
|
||||
<td class="bad">Fails loudly</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Restore attempted on a provisioned node</td>
|
||||
<td>The onboarding gate refuses identity-mutating RPCs once set up — a live node cannot be hijacked or accidentally re-seeded</td>
|
||||
<td class="ok">Refused</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Legacy or corrupt FIPS key format</td>
|
||||
<td>Self-heals: the legacy raw-byte format is detected and migrated in place to bech32</td>
|
||||
<td class="ok">Auto-migrated</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Readiness-ledger write fails</td>
|
||||
<td>Warns and continues — the audit trail is best-effort and can never block key generation</td>
|
||||
<td class="warn">Non-blocking</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<h4 class="bad">The one true single point of failure is you</h4>
|
||||
<p>
|
||||
Every software failure above fails <em>safe</em>. The only unrecoverable scenario is
|
||||
losing the 24 words <em>and</em> the node's disk together. Write the words down, store
|
||||
them offline, and never type them into anything except a node you are restoring. Anyone
|
||||
holding them can rebuild your entire identity tree — which is exactly what makes them a
|
||||
perfect backup and a perfect target.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 id="restore">Restore</h2>
|
||||
<p class="lede">Typing 24 words into a fresh node, step by step.</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<ol class="steps">
|
||||
<li><strong>Gate check.</strong> Restore only proceeds on an un-onboarded node. This gate is load-bearing and runs before anything else.</li>
|
||||
<li><strong>Validation.</strong> Exactly 24 words, checked against the BIP-39 wordlist and its checksum — a typo is caught here, before anything is written.</li>
|
||||
<li><strong>Identity regrowth.</strong> Node key, DID, node Nostr key and FIPS mesh key are re-derived byte-identically, because the HKDF labels are fixed.</li>
|
||||
<li><strong>Personal identity #0.</strong> The index resets to 0 and your default identity (Ed25519 + NIP-06 Nostr key) is recreated. Further seed-derived identities re-derive as the index walks forward, but their names and avatars were metadata, not key material.</li>
|
||||
<li><strong>Mesh reactivation.</strong> FIPS auto-activation starts in the background; the Reticulum identity re-derives from the restored node key, so your LXMF address returns too.</li>
|
||||
<li><strong>Password and re-seal.</strong> Setting the new login password re-encrypts the words into a fresh <code>master_seed.enc</code>, so Reveal works on the restored node.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h3>What comes back — and what doesn't</h3>
|
||||
<div class="grid">
|
||||
<div class="glass-card">
|
||||
<h4 class="ok">Restored by the words</h4>
|
||||
<p>Node identity and DID · node npub · FIPS mesh key · Reticulum/LXMF address · personal identity keys and npubs · message-store, contacts and credential encryption keys · the dormant Bitcoin xprv · the ability to reveal the phrase again.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4 class="warn">Needs its own backup</h4>
|
||||
<p>Lightning wallet (aezeed — one-way gate) and channel state · chat history and app data (node backup) · identity names and avatars · app secrets, which regenerate on reinstall.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4 class="bad">Gone by design</h4>
|
||||
<p>Sessions and device pairings (log in, re-pair) · 2FA secret (re-enrol) · WireGuard peers (re-pair) · rotated-away node keys · anonymous throwaway Nostr keys.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>SeedQR</h3>
|
||||
<p>
|
||||
Wherever the phrase is shown, a QR tab sits beside the words. For the BIP-39 phrase the
|
||||
default is <strong>SeedQR</strong>: each word becomes its 4-digit position in the official
|
||||
wordlist (24 words → 96 digits) as a compact numeric QR. Passport, SeedSigner and Keystone
|
||||
import this directly, so you can move your on-chain identity to cold storage without typing.
|
||||
A plain-text QR fallback exists for wallets that read the phrase as text.
|
||||
</p>
|
||||
<ul>
|
||||
<li>The QR holds <em>exactly the same secret</em> as the words — treat a printout or screenshot identically.</li>
|
||||
<li>The Lightning aezeed is never SeedQR-encoded: it is not BIP-39, hardware wallets cannot import it, and pretending otherwise would be dishonest. It gets a plain-text QR with an explanation.</li>
|
||||
<li>Restore is by typed or pasted words; there is no camera-based SeedQR scanner on the restore path today.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="verify">Verify it yourself</h2>
|
||||
<p class="lede">Don't trust — recompute.</p>
|
||||
|
||||
<p>Because every derivation is deterministic and label-fixed, you can independently confirm that this node's keys really do come from your words:</p>
|
||||
<ul>
|
||||
<li><strong>Independent re-derivation:</strong> <code>scripts/verify-seed-derivation.py</code> in the Archipelago source — pure standard-library Python, no Archipelago code. On a trusted offline machine it recomputes <code>node_key</code>, <code>nostr_secret</code> and <code>fips_key</code> from your mnemonic and byte-compares them against <code>/var/lib/archipelago/identity/</code>.</li>
|
||||
<li><strong>Known-answer tests:</strong> the test suite pins the exact expected keys for a fixed test mnemonic, so any change to the derivation math turns the build red.</li>
|
||||
<li><strong>Non-determinism test:</strong> 64 consecutive generated mnemonics are asserted unique — a canary against the predictable-RNG failure class.</li>
|
||||
<li><strong>Your own audit trail:</strong> <code>/var/lib/archipelago/security/csprng-readiness.jsonl</code> records, append-only, the kernel randomness verdict at every key-generation event on this node — including the moment your seed was born.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Honest edges</h3>
|
||||
<p>The audit that produced this system also tracked what it did not fix. Naming the edges is part of the point:</p>
|
||||
<ul>
|
||||
<li><strong>The words cross the RPC boundary.</strong> During onboarding the phrase travels to your browser to be displayed, sits in session storage for the wizard's duration, and is held in server memory for the 10-minute idempotence window — the price of a refresh-proof, display-once flow.</li>
|
||||
<li><strong>Argon2 uses library defaults</strong> (≈19 MiB, 2 passes) rather than the heavier profile the design doc specifies. Still memory-hard; scheduled for tightening.</li>
|
||||
<li><strong>2FA backup codes carry slight modulo bias</strong> — cosmetically imperfect, cryptographically irrelevant at their length, queued for cleanup.</li>
|
||||
<li><strong>The lint ban covers the main workspace</strong>, but one small helper crate outside it is not reached yet.</li>
|
||||
<li><strong>Best-effort sealing:</strong> if writing <code>master_seed.enc</code> fails during setup, the node continues (keys exist, only Reveal is lost). Whether that should fail loudly instead is under review.</li>
|
||||
</ul>
|
||||
|
||||
<div class="glass-card">
|
||||
<h4 class="ok">The whole story in one paragraph</h4>
|
||||
<p>
|
||||
Your node asked the Linux kernel for 32 bytes of hardware-grade randomness through a
|
||||
single, named, compiler-enforced channel; refused to proceed unless the bytes looked
|
||||
alive; wrote down the health of the random pool as evidence; turned the bytes into 24
|
||||
words it showed you exactly once; locked an encrypted copy behind your password; and then
|
||||
grew every identity and key it owns from those words along fixed, versioned,
|
||||
independently verifiable paths — so the words in your drawer are, and will remain, a
|
||||
complete blueprint of who your node is.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="./nav.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
// Scroll-spy for the sidebar, mirroring the dashboard's nav-tab-active state.
|
||||
// External file (not inline) because the node's CSP is script-src 'self'.
|
||||
(function () {
|
||||
var links = Array.prototype.slice.call(
|
||||
document.querySelectorAll('.sidebar-nav .sidebar-nav-item[href^="#"]')
|
||||
)
|
||||
if (!links.length || !('IntersectionObserver' in window)) return
|
||||
|
||||
var sections = links
|
||||
.map(function (a) { return document.getElementById(a.getAttribute('href').slice(1)) })
|
||||
.filter(Boolean)
|
||||
|
||||
function activate(id) {
|
||||
links.forEach(function (a) {
|
||||
a.classList.toggle('nav-tab-active', a.getAttribute('href') === '#' + id)
|
||||
})
|
||||
}
|
||||
|
||||
var visible = {}
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) { visible[e.target.id] = e.isIntersecting })
|
||||
// Topmost section currently on screen wins, so the highlight tracks reading position.
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
if (visible[sections[i].id]) { activate(sections[i].id); return }
|
||||
}
|
||||
}, { rootMargin: '-10% 0px -70% 0px', threshold: 0 })
|
||||
|
||||
sections.forEach(function (s) { observer.observe(s) })
|
||||
})()
|
||||
@@ -2,11 +2,15 @@
|
||||
<BaseModal :show="show" title="" max-width="max-w-lg" @close="emit('close')">
|
||||
<!-- Header: app icon + "Install Bitcoin Knots/Core" -->
|
||||
<div class="flex items-center gap-4 mb-5 -mt-2">
|
||||
<!-- object-contain, not the default fill: app icons are not all square
|
||||
(bitcoin-knots is not), so a fixed 56x56 box distorted or cropped the
|
||||
mark against the rounded corners. Contain plus a dark plate shows the
|
||||
whole icon whatever its aspect ratio. -->
|
||||
<img
|
||||
v-if="app?.icon"
|
||||
:src="app.icon"
|
||||
:alt="app?.title || ''"
|
||||
class="w-14 h-14 rounded-xl shadow-lg shrink-0"
|
||||
class="w-14 h-14 rounded-xl shadow-lg shrink-0 object-contain bg-black/40 p-1"
|
||||
/>
|
||||
<div v-else class="w-14 h-14 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
|
||||
<svg class="w-7 h-7 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -107,7 +107,21 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{ close: []; received: []; scan: [] }>()
|
||||
|
||||
watch(() => props.show, (open) => {
|
||||
if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) {
|
||||
if (!open) return
|
||||
// Blank slate on every open: a leftover amount/memo/token or a previous
|
||||
// invoice quietly carrying into a new receive flow is exactly the stale-
|
||||
// state class the operator flagged on the send modal (2026-08-05).
|
||||
receiveMethod.value = 'onchain'
|
||||
invoiceAmount.value = 0
|
||||
invoiceMemo.value = ''
|
||||
invoiceResult.value = ''
|
||||
onchainAddress.value = ''
|
||||
arkAddress.value = ''
|
||||
ecashToken.value = ''
|
||||
ecashResult.value = ''
|
||||
error.value = ''
|
||||
processing.value = false
|
||||
if (props.autoGenerate && receiveMethod.value === 'onchain') {
|
||||
void receive()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -327,15 +327,57 @@ const isSweep = computed(() => sendMethod.value === 'onchain' && sendAll.value)
|
||||
|
||||
function toggleSendAll() {
|
||||
sendAll.value = !sendAll.value
|
||||
if (sendAll.value && onchainBalance.value === null) {
|
||||
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
|
||||
.then((res) => { onchainBalance.value = res.balance_sats || 0 })
|
||||
.catch(() => { /* balance hint is best-effort */ })
|
||||
if (!sendAll.value) {
|
||||
// Disarming clears the field — a swept-balance figure left behind reads
|
||||
// as a typed amount.
|
||||
amount.value = 0
|
||||
return
|
||||
}
|
||||
// Arming shows the swept balance IN the (disabled) amount field — a field
|
||||
// stuck at 0 while "send all" is lit read as "sending nothing" (operator
|
||||
// feedback 2026-08-05). Refresh the figure on every arm.
|
||||
const applyBalance = () => {
|
||||
if (sendAll.value && onchainBalance.value !== null) amount.value = onchainBalance.value
|
||||
}
|
||||
applyBalance()
|
||||
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
|
||||
.then((res) => { onchainBalance.value = res.balance_sats || 0; applyBalance() })
|
||||
.catch(() => { /* balance hint is best-effort */ })
|
||||
}
|
||||
|
||||
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
|
||||
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
|
||||
// (and drops the swept-balance figure it wrote into the amount field).
|
||||
watch(sendMethod, (m) => {
|
||||
if (m !== 'onchain' && sendAll.value) {
|
||||
sendAll.value = false
|
||||
amount.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
// Every open starts from a blank slate. Stale state from the previous send —
|
||||
// destination, amount, and above all an armed "send all funds" toggle — is
|
||||
// dangerous to inherit invisibly (operator feedback 2026-08-05).
|
||||
watch(() => props.show, (shown) => {
|
||||
if (!shown) return
|
||||
sendMethod.value = 'lightning'
|
||||
amountUnit.value = 'sats'
|
||||
amountEntry.value = 0
|
||||
dest.value = ''
|
||||
error.value = ''
|
||||
successInfo.value = null
|
||||
ecashToken.value = ''
|
||||
sendAll.value = false
|
||||
onchainBalance.value = null
|
||||
feePreset.value = 'standard'
|
||||
customConfTarget.value = null
|
||||
customSatPerVbyte.value = null
|
||||
resolvedFeeParams.value = {}
|
||||
feeEstimate.value = null
|
||||
confirming.value = false
|
||||
confirmBalance.value = null
|
||||
invoiceAmountSats.value = null
|
||||
processing.value = false
|
||||
})
|
||||
|
||||
// --- On-chain network fee: presets map to LND confirmation targets; custom
|
||||
// --- takes a block target or an explicit sat/vB rate (rate wins).
|
||||
|
||||
@@ -51,10 +51,16 @@
|
||||
</svg>
|
||||
<p class="text-sm text-white/60 text-center">How do you want to read the QR?</p>
|
||||
<!-- hasNativeQr: on the companion (plain http, no getUserMedia)
|
||||
the native bridge still provides a live camera -->
|
||||
<button v-if="!liveCameraUnavailable || hasNativeQr" @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||
the native bridge still provides a live camera. On plain-http
|
||||
desktop the button stays VISIBLE — hiding it read as "the
|
||||
scanner is gone" (operator, 2026-08-05); choosing it surfaces
|
||||
the browser's HTTPS requirement with the fallbacks instead. -->
|
||||
<button @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||
Scan with camera
|
||||
</button>
|
||||
<p v-if="liveCameraUnavailable && !hasNativeQr" class="text-[11px] text-white/40 text-center -mt-1">
|
||||
Your browser only allows live camera on HTTPS pages — the photo and paste options below always work.
|
||||
</p>
|
||||
<button @click="photoInput?.click()" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||
Upload / take a photo of the QR
|
||||
</button>
|
||||
|
||||
@@ -863,12 +863,35 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
}
|
||||
|
||||
async function rebootRadio(seconds = 2) {
|
||||
return rpcClient.call<{ reboot: boolean; seconds: number }>({
|
||||
// Long timeout: Reticulum reboots restart the sidecar daemon and the
|
||||
// backend waits for the acknowledgement instead of fire-and-forgetting.
|
||||
return rpcClient.call<{ reboot: boolean; seconds: number; message?: string }>({
|
||||
method: 'mesh.reboot-radio',
|
||||
params: { seconds },
|
||||
timeout: 30000,
|
||||
})
|
||||
}
|
||||
|
||||
/** Persisted RNode RF settings + live radio-confirmed state (Reticulum). */
|
||||
async function getRnodeConfig() {
|
||||
return rpcClient.call<{
|
||||
settings: Record<string, unknown>
|
||||
live: Record<string, unknown> | null
|
||||
live_error: string | null
|
||||
}>({ method: 'mesh.rnode-config', timeout: 20000 })
|
||||
}
|
||||
|
||||
/** Apply RNode RF settings: persists, restarts the radio daemon, waits for
|
||||
* the radio's own read-back confirmation (up to ~50s). */
|
||||
async function applyRnodeConfig(settings: Record<string, unknown>) {
|
||||
return rpcClient.call<{
|
||||
applied: boolean
|
||||
confirmed?: boolean
|
||||
live?: Record<string, unknown> | null
|
||||
message: string
|
||||
}>({ method: 'mesh.rnode-config-apply', params: { settings }, timeout: 70000 })
|
||||
}
|
||||
|
||||
async function getOutbox() {
|
||||
try {
|
||||
return await rpcClient.call<{ count: number; messages?: unknown[] }>({ method: 'mesh.outbox' })
|
||||
@@ -1155,6 +1178,8 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
sendReply,
|
||||
sendReaction,
|
||||
rebootRadio,
|
||||
getRnodeConfig,
|
||||
applyRnodeConfig,
|
||||
getOutbox,
|
||||
sendReadReceipt,
|
||||
forwardMessage,
|
||||
|
||||
@@ -311,31 +311,26 @@ const showChatPanel = computed(() =>
|
||||
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
|
||||
)
|
||||
const showBitcoinPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'bitcoin'
|
||||
if (isMobile.value) return mobileTab.value === 'bitcoin'
|
||||
return activeTab.value === 'bitcoin'
|
||||
})
|
||||
const showDeadmanPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'deadman'
|
||||
if (isMobile.value) return mobileTab.value === 'deadman'
|
||||
return activeTab.value === 'deadman'
|
||||
})
|
||||
const showAssistantPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'assistant'
|
||||
if (isMobile.value) return mobileTab.value === 'assistant'
|
||||
return activeTab.value === 'assistant'
|
||||
})
|
||||
const showMapPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'map'
|
||||
if (isMobile.value) return mobileTab.value === 'map'
|
||||
return activeTab.value === 'map'
|
||||
})
|
||||
const showDevicePanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'device'
|
||||
if (isMobile.value) return mobileTab.value === 'device'
|
||||
return activeTab.value === 'device'
|
||||
@@ -2683,7 +2678,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
|
||||
<!-- Tools panels (3rd column on wide screens) -->
|
||||
<div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
|
||||
<div v-if="isWideDesktop && !isVeryWideDesktop" class="mesh-tools-tab-bar">
|
||||
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">Bitcoin</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
|
||||
Dead Man
|
||||
|
||||
@@ -4,12 +4,15 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
"aiui": 5180,
|
||||
"archy-mempool-web": 4080,
|
||||
"archy-nbxplorer": 32838,
|
||||
"bitcoin-ui": 8334,
|
||||
"botfights": 9100,
|
||||
"btcpay-server": 23000,
|
||||
"did-wallet": 8088,
|
||||
"electrs-ui": 50002,
|
||||
"electrumx": 50002,
|
||||
"fedimint": 8175,
|
||||
"filebrowser": 8083,
|
||||
"fips-ui": 8336,
|
||||
"gitea": 3001,
|
||||
"grafana": 3000,
|
||||
"homeassistant": 8123,
|
||||
|
||||
@@ -7,12 +7,17 @@ const mesh = useMeshStore()
|
||||
|
||||
const rebooting = ref(false)
|
||||
const rebootError = ref<string | null>(null)
|
||||
const rebootMessage = ref<string | null>(null)
|
||||
|
||||
async function handleReboot() {
|
||||
rebooting.value = true
|
||||
rebootError.value = null
|
||||
rebootMessage.value = null
|
||||
try {
|
||||
await mesh.rebootRadio()
|
||||
const res = await mesh.rebootRadio()
|
||||
// The backend now waits for the device's acknowledgement and says what
|
||||
// actually happened — show it instead of silently going idle again.
|
||||
rebootMessage.value = res.message || 'Reboot command acknowledged by the radio.'
|
||||
} catch (e) {
|
||||
rebootError.value = e instanceof Error ? e.message : 'Failed to reboot radio'
|
||||
} finally {
|
||||
@@ -20,6 +25,121 @@ async function handleReboot() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── RNode (Reticulum) RF settings — full round-trip with device read-back ──
|
||||
// Recommended plans per region for Reticulum RNode radios. EU868 is the
|
||||
// operator-validated Portugal plan (869.4625 MHz keeps clear of the default
|
||||
// community channel while staying in the 10%-duty 869.4–869.65 sub-band;
|
||||
// airtime locks match EU duty-cycle law). Others use the RNS community
|
||||
// conventions for the band with the region's legal power cap.
|
||||
const RNODE_REGION_PLANS: Record<string, { frequency: number; bandwidth: number; spreading_factor: number; coding_rate: number; txpower: number; airtime_limit_short: number | null; airtime_limit_long: number | null }> = {
|
||||
EU868: { frequency: 869462500, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 14, airtime_limit_short: 25, airtime_limit_long: 10 },
|
||||
US915: { frequency: 914875000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
|
||||
AU915: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
|
||||
ANZ: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
|
||||
AS923: { frequency: 923200000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 13, airtime_limit_short: null, airtime_limit_long: null },
|
||||
IN865: { frequency: 866000000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
|
||||
}
|
||||
|
||||
const rnodeForm = ref({
|
||||
enabled: true,
|
||||
port: '',
|
||||
frequency: '',
|
||||
bandwidth: '125000',
|
||||
spreading_factor: '8',
|
||||
coding_rate: '5',
|
||||
txpower: '17',
|
||||
airtime_limit_short: '',
|
||||
airtime_limit_long: '',
|
||||
})
|
||||
const rnodeLive = ref<Record<string, unknown> | null>(null)
|
||||
const rnodeLiveError = ref<string | null>(null)
|
||||
const rnodeLoading = ref(false)
|
||||
const rnodeApplying = ref(false)
|
||||
const rnodeResult = ref<{ ok: boolean; confirmed: boolean; message: string } | null>(null)
|
||||
let rnodeSeeded = false
|
||||
|
||||
const rnodeRegionPlan = computed(() => (form.value.region ? RNODE_REGION_PLANS[form.value.region] : undefined))
|
||||
|
||||
function setRnodeRecommendedForRegion() {
|
||||
const plan = rnodeRegionPlan.value
|
||||
if (!plan) return
|
||||
rnodeForm.value.frequency = String(plan.frequency)
|
||||
rnodeForm.value.bandwidth = String(plan.bandwidth)
|
||||
rnodeForm.value.spreading_factor = String(plan.spreading_factor)
|
||||
rnodeForm.value.coding_rate = String(plan.coding_rate)
|
||||
rnodeForm.value.txpower = String(plan.txpower)
|
||||
rnodeForm.value.airtime_limit_short = plan.airtime_limit_short != null ? String(plan.airtime_limit_short) : ''
|
||||
rnodeForm.value.airtime_limit_long = plan.airtime_limit_long != null ? String(plan.airtime_limit_long) : ''
|
||||
}
|
||||
|
||||
async function loadRnodeConfig() {
|
||||
rnodeLoading.value = true
|
||||
try {
|
||||
const res = await mesh.getRnodeConfig()
|
||||
rnodeLive.value = res.live
|
||||
rnodeLiveError.value = res.live_error
|
||||
const s = res.settings as Record<string, unknown>
|
||||
if (!rnodeSeeded && s) {
|
||||
rnodeSeeded = true
|
||||
rnodeForm.value.enabled = s.enabled !== false
|
||||
rnodeForm.value.port = (s.port as string) ?? ''
|
||||
rnodeForm.value.frequency = String(s.frequency ?? '')
|
||||
rnodeForm.value.bandwidth = String(s.bandwidth ?? '125000')
|
||||
rnodeForm.value.spreading_factor = String(s.spreading_factor ?? '8')
|
||||
rnodeForm.value.coding_rate = String(s.coding_rate ?? '5')
|
||||
rnodeForm.value.txpower = String(s.txpower ?? '17')
|
||||
rnodeForm.value.airtime_limit_short = s.airtime_limit_short != null ? String(s.airtime_limit_short) : ''
|
||||
rnodeForm.value.airtime_limit_long = s.airtime_limit_long != null ? String(s.airtime_limit_long) : ''
|
||||
}
|
||||
} catch (e) {
|
||||
rnodeLiveError.value = e instanceof Error ? e.message : 'Could not load RNode settings'
|
||||
} finally {
|
||||
rnodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyRnodeSettings() {
|
||||
rnodeApplying.value = true
|
||||
rnodeResult.value = null
|
||||
try {
|
||||
const res = await mesh.applyRnodeConfig({
|
||||
enabled: rnodeForm.value.enabled,
|
||||
port: rnodeForm.value.port.trim() || null,
|
||||
frequency: Number(rnodeForm.value.frequency),
|
||||
bandwidth: Number(rnodeForm.value.bandwidth),
|
||||
spreading_factor: Number(rnodeForm.value.spreading_factor),
|
||||
coding_rate: Number(rnodeForm.value.coding_rate),
|
||||
txpower: Number(rnodeForm.value.txpower),
|
||||
airtime_limit_short: rnodeForm.value.airtime_limit_short === '' ? null : Number(rnodeForm.value.airtime_limit_short),
|
||||
airtime_limit_long: rnodeForm.value.airtime_limit_long === '' ? null : Number(rnodeForm.value.airtime_limit_long),
|
||||
})
|
||||
rnodeResult.value = { ok: res.applied, confirmed: !!res.confirmed, message: res.message }
|
||||
if (res.live) rnodeLive.value = res.live
|
||||
} catch (e) {
|
||||
rnodeResult.value = { ok: false, confirmed: false, message: e instanceof Error ? e.message : 'Apply failed' }
|
||||
} finally {
|
||||
rnodeApplying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRnodeLive() {
|
||||
rnodeLoading.value = true
|
||||
try {
|
||||
const res = await mesh.getRnodeConfig()
|
||||
rnodeLive.value = res.live
|
||||
rnodeLiveError.value = res.live_error
|
||||
} catch (e) {
|
||||
rnodeLiveError.value = e instanceof Error ? e.message : 'Could not read the radio state'
|
||||
} finally {
|
||||
rnodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fmtMhz(v: unknown): string {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) && n > 0 ? `${(n / 1e6).toFixed(4)} MHz` : '—'
|
||||
}
|
||||
|
||||
// ── Editable settings (persisted via mesh.configure) ──
|
||||
const form = ref({
|
||||
region: '',
|
||||
@@ -157,6 +277,18 @@ async function saveSettings() {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load the RNode settings + live state as soon as the panel knows a
|
||||
// Reticulum radio is (or is pinned as) the device. Declared LAST: with
|
||||
// `immediate: true` the source getter runs at setup, and `effectiveKind`
|
||||
// must already exist (the SendBitcoinModal TDZ-crash lesson).
|
||||
watch(
|
||||
() => effectiveKind.value,
|
||||
(kind) => {
|
||||
if (kind === 'reticulum') void loadRnodeConfig()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -202,7 +334,7 @@ async function saveSettings() {
|
||||
Program the radio's RF settings with the fields below — every radio on your mesh must match{{ selectedRegion ? ` (${selectedRegion.band} MHz band)` : '' }}.
|
||||
</p>
|
||||
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
|
||||
RNode RF parameters are managed by the Reticulum daemon's interface config on this node.
|
||||
Pick your region, then use "Set recommended for region" in the RNode section below.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -271,6 +403,96 @@ async function saveSettings() {
|
||||
Saved settings program the radio on its next connect (it reboots once to apply). Leave all four empty to keep the radio's own settings.
|
||||
</p>
|
||||
</div>
|
||||
<!-- RNode (Reticulum) RF settings: the device's CURRENT values shown
|
||||
first (radio-confirmed read-back), then every parameter editable,
|
||||
with apply → device confirmation. Actions stack in a column. -->
|
||||
<div v-if="effectiveKind === 'reticulum'" class="mt-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h5 class="text-xs font-semibold text-white/80">RNode radio — current device settings</h5>
|
||||
<button class="text-[11px] text-sky-300/80 hover:text-sky-200 disabled:opacity-50" :disabled="rnodeLoading" @click="refreshRnodeLive">
|
||||
{{ rnodeLoading ? 'Reading…' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="rnodeLive" class="rounded-lg bg-white/[0.04] border border-white/10 p-3 mb-3 grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||
<div><span class="text-white/40 block">Status</span><span :class="rnodeLive.online ? 'text-green-400' : 'text-amber-400'">{{ rnodeLive.online ? 'Online' : 'Detected, not online' }}</span></div>
|
||||
<div><span class="text-white/40 block">Port</span><span class="text-white/80">{{ rnodeLive.port || '—' }}</span></div>
|
||||
<div><span class="text-white/40 block">Frequency</span><span class="text-white/80">{{ fmtMhz(rnodeLive.r_frequency ?? rnodeLive.frequency) }}</span></div>
|
||||
<div><span class="text-white/40 block">Bandwidth</span><span class="text-white/80">{{ rnodeLive.r_bandwidth ?? rnodeLive.bandwidth ?? '—' }} Hz</span></div>
|
||||
<div><span class="text-white/40 block">Spreading</span><span class="text-white/80">SF {{ rnodeLive.r_spreadingfactor ?? rnodeLive.spreadingfactor ?? '—' }}</span></div>
|
||||
<div><span class="text-white/40 block">Coding rate</span><span class="text-white/80">4/{{ rnodeLive.r_codingrate ?? rnodeLive.codingrate ?? '—' }}</span></div>
|
||||
<div><span class="text-white/40 block">TX power</span><span class="text-white/80">{{ rnodeLive.r_txpower ?? rnodeLive.txpower ?? '—' }} dBm</span></div>
|
||||
<div><span class="text-white/40 block">Airtime limits</span><span class="text-white/80">{{ rnodeLive.r_airtime_limit_short ?? rnodeLive.airtime_limit_short ?? '—' }}% / {{ rnodeLive.r_airtime_limit_long ?? rnodeLive.airtime_limit_long ?? '—' }}%</span></div>
|
||||
</div>
|
||||
<p v-else-if="rnodeLiveError" class="text-[11px] text-amber-400/80 mb-3">{{ rnodeLiveError }}</p>
|
||||
|
||||
<h5 class="text-xs font-semibold text-white/80 mb-2">RNode RF parameters</h5>
|
||||
<div class="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Frequency (Hz)</label>
|
||||
<input v-model="rnodeForm.frequency" inputmode="numeric" placeholder="869462500" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Bandwidth (Hz)</label>
|
||||
<select v-model="rnodeForm.bandwidth" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option v-for="bw in ['7800','10400','15600','20800','31250','41700','62500','125000','250000','500000']" :key="bw" :value="bw">{{ bw }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Spreading factor</label>
|
||||
<select v-model="rnodeForm.spreading_factor" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option v-for="sf in [5,6,7,8,9,10,11,12]" :key="sf" :value="String(sf)">SF {{ sf }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Coding rate</label>
|
||||
<select v-model="rnodeForm.coding_rate" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option v-for="cr in [5,6,7,8]" :key="cr" :value="String(cr)">4/{{ cr }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">TX power (dBm)</label>
|
||||
<input v-model="rnodeForm.txpower" inputmode="numeric" placeholder="14" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Airtime short (%)</label>
|
||||
<input v-model="rnodeForm.airtime_limit_short" inputmode="decimal" placeholder="25" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Airtime long (%)</label>
|
||||
<input v-model="rnodeForm.airtime_limit_long" inputmode="decimal" placeholder="10" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Serial port</label>
|
||||
<input v-model="rnodeForm.port" placeholder="auto-detect" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
|
||||
<input v-model="rnodeForm.enabled" type="checkbox" class="h-4 w-4 accent-orange-500" />
|
||||
RNode interface enabled
|
||||
</label>
|
||||
|
||||
<!-- Actions: stacked in a column on purpose (operator layout request) -->
|
||||
<div class="flex flex-col gap-2 mt-4 max-w-sm">
|
||||
<button
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
:disabled="!rnodeRegionPlan || rnodeApplying"
|
||||
@click="setRnodeRecommendedForRegion"
|
||||
>
|
||||
{{ rnodeRegionPlan ? `Set recommended for ${form.region}` : 'Pick a region above first' }}
|
||||
</button>
|
||||
<button
|
||||
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
:disabled="rnodeApplying"
|
||||
@click="applyRnodeSettings"
|
||||
>
|
||||
{{ rnodeApplying ? 'Applying — waiting for the radio to confirm…' : 'Apply & Confirm on Device' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="rnodeResult" class="text-xs mt-2" :class="rnodeResult.ok && rnodeResult.confirmed ? 'text-green-400' : rnodeResult.ok ? 'text-amber-400' : 'text-red-400'">
|
||||
<template v-if="rnodeResult.ok && rnodeResult.confirmed">✓ </template>{{ rnodeResult.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
|
||||
<input v-model="form.broadcastIdentity" type="checkbox" class="h-4 w-4 accent-orange-500" />
|
||||
Periodically broadcast this node's identity on the mesh
|
||||
@@ -304,6 +526,7 @@ async function saveSettings() {
|
||||
<template v-else>Reboot Radio</template>
|
||||
</button>
|
||||
<p class="mesh-device-reboot-hint">Use this if the device stops responding to sent messages or seems stuck.</p>
|
||||
<p v-if="rebootMessage" class="text-xs text-green-400 mt-1">{{ rebootMessage }}</p>
|
||||
<p v-if="rebootError" class="mesh-device-reboot-error">{{ rebootError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,17 +43,26 @@
|
||||
.mesh-columns-wide .mesh-chat-card { grid-column: 2; grid-row: 1; min-height: 0; overflow: hidden; }
|
||||
.mesh-columns-wide .mesh-tools-wrapper { grid-column: 3; grid-row: 1; display: flex; flex-direction: column; gap: 0; min-height: 0; overflow: hidden; }
|
||||
.mesh-columns-wide .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; margin-bottom: 12px; }
|
||||
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(420px, 0.95fr); }
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper { display: grid; grid-template-rows: minmax(0, 1fr) minmax(0, 0.85fr) minmax(0, 1fr); gap: 12px; overflow: hidden; }
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-deadman-panel,
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-assistant-panel,
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-map-panel { min-height: 0; height: 100%; overflow: hidden; }
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-bitcoin-panel,
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-deadman-panel,
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-assistant-panel,
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-map-panel { flex: 1 1 auto; min-height: 0; height: auto; }
|
||||
.mesh-columns-very-wide .mesh-tools-tab-bar { display: none; }
|
||||
/* A very wide screen gets a roomier third column — but the SAME tabbed
|
||||
panel as every other desktop width. It used to stack Bitcoin, Dead Man,
|
||||
AI, the map and Device on top of each other in fixed grid rows, which on
|
||||
a real 2560px display clipped the first three headings to a few pixels,
|
||||
letterboxed the map, and pushed Radio Settings into a scroll — more
|
||||
screen producing a worse view (reported with a screenshot 2026-08-05).
|
||||
One tab at a time, filling the column, is what makes the map edge to
|
||||
edge and every control reachable without scrolling. */
|
||||
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(460px, 1fr); }
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-deadman-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-assistant-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-device-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel {
|
||||
flex: 1 1 auto; min-height: 0; height: auto; overflow-y: auto;
|
||||
}
|
||||
/* The map is the one panel with nothing to scroll: let it consume the
|
||||
column edge to edge rather than sitting in a letterbox. */
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel { overflow: hidden; padding: 0; }
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel > * { height: 100%; width: 100%; }
|
||||
.mesh-columns-wide .mesh-mobile-back-btn,
|
||||
.mesh-columns-wide .mesh-tab-bar { display: none; }
|
||||
.mesh-status-card { padding: 16px; flex-shrink: 0; }
|
||||
|
||||
@@ -362,6 +362,70 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.125-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.125-alpha</span>
|
||||
<span class="text-xs text-white/40">August 6, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.</strong> Since the gate went up, those screens would load their frame and then show every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.</p>
|
||||
<p><strong>IndeeHub heals itself.</strong> Three separate faults: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted about ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — the node would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. All three are fixed, and the storage fault fixes the same trap for every future multi-part app.</p>
|
||||
<p><strong>Send and Receive open clean every time.</strong> Whatever you typed last — an address, an amount, and above all an armed "send all funds" toggle — no longer quietly carries over into the next payment. And choosing "send all funds" now shows the amount being swept instead of a confusing 0.</p>
|
||||
<p><strong>The camera scanner option no longer vanishes on desktop.</strong> Browsers only allow the live camera on secure (HTTPS) pages, and the scan window used to silently hide the camera choice on plain connections, which read as "the scanner is gone". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.</p>
|
||||
<p>Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.124-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.124-alpha</span>
|
||||
<span class="text-xs text-white/40">August 5, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>The most important fix here: some nodes were left switched off by their own update, and could not switch themselves back on.</strong> The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it <em>crashes</em>. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts.</p>
|
||||
<p><strong>Portainer opens again.</strong> Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1 and their stored data was written by that version, while the app list pinned a version from two years earlier — so a rebuilt container landed on the old one, which will not read newer data. The correct version is pinned now, older installs upgrade cleanly, and no data was touched.</p>
|
||||
<p><strong>Bitcoin starts reliably again.</strong> A leftover settings file in the Bitcoin folder — one the node kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes it, clears stale copies, and treats any that remain as harmless.</p>
|
||||
<p><strong>Every app screen opens from My Apps again.</strong> The login gate refused to be displayed inside another page at all — which is exactly how My Apps opens an app — so protected apps looked broken. It now allows only your own node to display it and refuses everyone else.</p>
|
||||
<p><strong>The app login screen looks like the node's own now:</strong> same rotating artwork, same panel, the Archipelago mark, and the app's real icon as a tile the way My Apps shows it, instead of a plain box with a letter.</p>
|
||||
<p><strong>The Mesh screen uses wide displays properly.</strong> On very large screens it stacked all five panels, clipping three headings to a sliver and squeezing the map into a letterbox. It now shows one panel at a time, filling the space, with the map edge to edge.</p>
|
||||
<p><strong>You can choose how long you stay signed in.</strong> Settings → Account offers an inactivity timeout, a hard limit, and an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle — there is nobody there to sign them back in.</p>
|
||||
<p>Updates now come from source.archipelago-foundation.org rather than a bare address, with the old one kept as an automatic fallback. Also: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.</p>
|
||||
<p>Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.123-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.123-alpha</span>
|
||||
<span class="text-xs text-white/40">August 5, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>Five more screens on your node were readable by anyone who could reach it — and the last release's own check said they were fine.</strong> The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It neither protected them nor listed them as unprotected. Found by scanning the node from another machine instead of asking the node about itself.</p>
|
||||
<p><strong>What was readable was the page, not your money.</strong> Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was checked directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.</p>
|
||||
<p>All five now serve only to the node itself, with the login gate in front, exactly like the twenty app screens closed in the last release.</p>
|
||||
<p><strong>Every port on your node now has a stated policy — none are left undecided.</strong> Eleven had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now ask for your node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections, NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session), Pine's secure listener, and the Lightning REST port that wallets reach with a macaroon exactly as before.</p>
|
||||
<p>Freshly flashed nodes are covered too, not only existing ones — those five screens ship as prebuilt images, so a new node would otherwise have come up open even after this fix. All five were rebuilt, published, then pulled back and inspected to confirm the fix is really inside them.</p>
|
||||
<p>Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.122-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.122-alpha</span>
|
||||
<span class="text-xs text-white/40">August 4, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.</strong> Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app.</p>
|
||||
<p><strong>The things that must stay open stayed open.</strong> Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.</p>
|
||||
<p><strong>A private address on your node was answering the mesh without a password.</strong> One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while checking the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.</p>
|
||||
<p><strong>Tor addresses no longer skip the login.</strong> An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session. Those addresses now arrive at the login gate first, closing the last route that went around it.</p>
|
||||
<p>Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after being told to move, and each would have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting.</p>
|
||||
<p>The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened. Both now come from the signed list.</p>
|
||||
<p><strong>The key that signs these updates has been replaced.</strong> The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.</p>
|
||||
<p>Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting is what caused two incidents this week. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser apps — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will meet the login page and need an access token; say the word if you use one and it can be exempted. The 5x real-node lifecycle gate was not run for this release.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.121-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import AccountInfoSection from '@/views/settings/AccountInfoSection.vue'
|
||||
import ChangePasswordSection from '@/views/settings/ChangePasswordSection.vue'
|
||||
import TwoFactorSection from '@/views/settings/TwoFactorSection.vue'
|
||||
import SessionTimeoutSection from '@/views/settings/SessionTimeoutSection.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
@@ -24,6 +25,7 @@ async function handleLogout() {
|
||||
<AccountInfoSection />
|
||||
<ChangePasswordSection />
|
||||
<TwoFactorSection />
|
||||
<SessionTimeoutSection />
|
||||
|
||||
<!-- Logout Button -->
|
||||
<button
|
||||
|
||||
@@ -289,6 +289,15 @@ defineExpose({ loadBackups })
|
||||
(and 2FA code, if enabled). Only reveal it somewhere private — anyone with these
|
||||
words controls this node.
|
||||
</p>
|
||||
<a
|
||||
href="/entropy/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-1 mt-2 text-sm text-orange-300/90 hover:text-orange-200 transition-colors"
|
||||
>
|
||||
How your seed & keys work — the full guide
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* How long a login lasts on this node.
|
||||
*
|
||||
* Presented as two plain questions — "sign me out after quiet" and "always
|
||||
* sign me out after" — rather than as the two-token mechanism underneath.
|
||||
* The distinction that matters to the operator is that the second one is
|
||||
* what actually guarantees a login ends: this dashboard polls constantly,
|
||||
* so an idle timeout alone never fires on an open tab.
|
||||
*/
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
type Policy = {
|
||||
idle_timeout_secs: number
|
||||
absolute_timeout_secs: number | null
|
||||
reauth_for_funds: boolean
|
||||
}
|
||||
|
||||
const idle = ref<number>(86400)
|
||||
const absolute = ref<number | null>(30 * 24 * 3600)
|
||||
const reauthForFunds = ref(true)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Offered as presets rather than a free number field: the useful values are
|
||||
// few, and a box accepting "5" invites someone to lock themselves out.
|
||||
const idleChoices = [
|
||||
{ label: '15 minutes', value: 15 * 60 },
|
||||
{ label: '1 hour', value: 3600 },
|
||||
{ label: '1 day', value: 86400 },
|
||||
{ label: '1 week', value: 7 * 24 * 3600 },
|
||||
{ label: '30 days', value: 30 * 24 * 3600 },
|
||||
]
|
||||
const absoluteChoices = [
|
||||
{ label: '1 day', value: 86400 },
|
||||
{ label: '1 week', value: 7 * 24 * 3600 },
|
||||
{ label: '30 days', value: 30 * 24 * 3600 },
|
||||
{ label: '90 days', value: 90 * 24 * 3600 },
|
||||
{ label: 'Never', value: null },
|
||||
]
|
||||
|
||||
const shortIdleWarning = computed(() => idle.value <= 3600)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const p = await rpcClient.call<Policy>({ method: 'auth.session-policy.get', params: {} })
|
||||
idle.value = p.idle_timeout_secs
|
||||
absolute.value = p.absolute_timeout_secs
|
||||
reauthForFunds.value = p.reauth_for_funds
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
error.value = ''
|
||||
try {
|
||||
// The backend clamps and returns what it stored, so reflect that back
|
||||
// rather than assuming our values were taken verbatim.
|
||||
const p = await rpcClient.call<Policy>({
|
||||
method: 'auth.session-policy.set',
|
||||
params: {
|
||||
idle_timeout_secs: idle.value,
|
||||
absolute_timeout_secs: absolute.value,
|
||||
reauth_for_funds: reauthForFunds.value,
|
||||
},
|
||||
})
|
||||
idle.value = p.idle_timeout_secs
|
||||
absolute.value = p.absolute_timeout_secs
|
||||
reauthForFunds.value = p.reauth_for_funds
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2500)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-base font-medium text-white/90 mb-1">Session timeout</h3>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
How long this node keeps you signed in. TV and kiosk screens are never
|
||||
signed out for sitting idle — there is nobody there to sign them back in.
|
||||
</p>
|
||||
|
||||
<div v-if="error" role="alert" class="mb-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg text-red-200 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="!loading" class="space-y-4">
|
||||
<div>
|
||||
<label for="idle-timeout" class="block text-sm font-medium text-white/80 mb-2">Sign me out after this much inactivity</label>
|
||||
<select
|
||||
id="idle-timeout"
|
||||
v-model.number="idle"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
|
||||
>
|
||||
<option v-for="c in idleChoices" :key="c.value" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
|
||||
</select>
|
||||
<p v-if="shortIdleWarning" class="text-xs text-orange-300/80 mt-2">
|
||||
Short timeouts are what payment-industry rules ask for when funds are involved — expect to sign in often.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="absolute-timeout" class="block text-sm font-medium text-white/80 mb-2">Always sign me out after</label>
|
||||
<select
|
||||
id="absolute-timeout"
|
||||
v-model="absolute"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
|
||||
>
|
||||
<option v-for="c in absoluteChoices" :key="String(c.value)" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
|
||||
</select>
|
||||
<p class="text-xs text-white/50 mt-2">
|
||||
Counts from when you signed in, whatever you are doing. This is the one that
|
||||
guarantees a session ends: an open dashboard is never idle, so the setting
|
||||
above would not fire on it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input v-model="reauthForFunds" type="checkbox" class="mt-1 accent-orange-500" />
|
||||
<span class="text-sm text-white/80">
|
||||
Ask for my password again before sending funds
|
||||
<span class="block text-xs text-white/50">Recommended. Applies however recently you signed in.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
:disabled="saving"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 disabled:opacity-50"
|
||||
@click="save"
|
||||
>
|
||||
{{ saving ? 'Saving…' : (saved ? 'Saved' : 'Save session settings') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user