Compare commits
No commits in common. "main" and "release/1.7.115-prep" have entirely different histories.
main
...
release/1.
18
.github/pull_request_template.md
vendored
18
.github/pull_request_template.md
vendored
@ -1,16 +1,16 @@
|
|||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
<!-- What changed and why? -->
|
<!-- Brief description of what this PR does -->
|
||||||
|
|
||||||
## Verification
|
## Changes
|
||||||
|
|
||||||
<!-- Commands run, devices tested, screenshots, or reason testing was not run. -->
|
-
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] Rust formatting/clippy/tests pass when backend code changed.
|
- [ ] TypeScript type-check passes (`npm run type-check`)
|
||||||
- [ ] Frontend type-check/build/tests pass when frontend code changed.
|
- [ ] Frontend builds (`npm run build`)
|
||||||
- [ ] App manifests validate when app packaging changed.
|
- [ ] Tests pass (`npm test`)
|
||||||
- [ ] Generated catalogs are updated when manifest-owned catalog fields changed.
|
- [ ] Rust clippy clean (if backend changes)
|
||||||
- [ ] Docs are updated for user-facing or developer-facing behavior changes.
|
- [ ] No new compiler warnings
|
||||||
- [ ] No secrets, generated build outputs, local screenshots, or private host details are included.
|
- [ ] Tested on live server
|
||||||
|
|||||||
31
.github/workflows/ci.yml
vendored
31
.github/workflows/ci.yml
vendored
@ -8,11 +8,11 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
RUST_VERSION: stable
|
RUST_VERSION: stable
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 18
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rust:
|
rust:
|
||||||
name: Rust
|
name: Rust (fmt + clippy + test)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@ -28,17 +28,17 @@ jobs:
|
|||||||
toolchain: ${{ env.RUST_VERSION }}
|
toolchain: ${{ env.RUST_VERSION }}
|
||||||
components: rustfmt, clippy
|
components: rustfmt, clippy
|
||||||
|
|
||||||
- name: Format
|
- name: Check formatting
|
||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
- name: Clippy
|
- name: Clippy
|
||||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
- name: Test
|
- name: Tests
|
||||||
run: cargo test --all-features
|
run: cargo test --all-features
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend
|
name: Frontend (type-check + lint)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@ -52,31 +52,14 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: npm
|
cache: 'npm'
|
||||||
cache-dependency-path: neode-ui/package-lock.json
|
cache-dependency-path: neode-ui/package-lock.json
|
||||||
|
|
||||||
- name: Install
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npm run type-check
|
run: npm run type-check
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
manifests:
|
|
||||||
name: App Manifests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Validate manifests
|
|
||||||
run: |
|
|
||||||
for manifest in apps/*/manifest.yml; do
|
|
||||||
./scripts/validate-app-manifest.sh --repo-audit "$manifest"
|
|
||||||
done
|
|
||||||
|
|||||||
35
.gitignore
vendored
35
.gitignore
vendored
@ -1,9 +1,10 @@
|
|||||||
# SSH keys and sandbox copies
|
# SSH keys (sandbox copies)
|
||||||
.ssh/
|
.ssh/
|
||||||
|
|
||||||
# Rust build output
|
# Rust build output
|
||||||
target/
|
target/
|
||||||
**/target/
|
**/target/
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
# Node.js
|
# Node.js
|
||||||
node_modules/
|
node_modules/
|
||||||
@ -11,6 +12,7 @@ node_modules/
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
package-lock.json
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
@ -26,46 +28,49 @@ build/
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
._*
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Environment and local overrides
|
# Environment and local overrides
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
.env.production
|
|
||||||
core/.env.production
|
|
||||||
scripts/deploy-config.sh
|
scripts/deploy-config.sh
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
.nyc_output/
|
.nyc_output/
|
||||||
|
|
||||||
# Image / release artifacts
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
*.iso
|
*.iso
|
||||||
*.img
|
*.img
|
||||||
*.dmg
|
*.dmg
|
||||||
*.app
|
*.app
|
||||||
*.apk
|
|
||||||
*.keystore
|
|
||||||
*.s9pk
|
|
||||||
*.tar.gz
|
|
||||||
|
|
||||||
# Release artifacts live in release attachments, not Git history.
|
# Release artifacts live in Gitea Release attachments, not Git history.
|
||||||
releases/**
|
releases/**
|
||||||
!releases/
|
!releases/
|
||||||
!releases/manifest.json
|
!releases/manifest.json
|
||||||
|
|
||||||
|
# macOS build output
|
||||||
|
build/macos/
|
||||||
|
|
||||||
# Image recipe output
|
# Image recipe output
|
||||||
image-recipe/output/
|
image-recipe/output/
|
||||||
image-recipe/*.iso
|
image-recipe/*.iso
|
||||||
image-recipe/*.img
|
image-recipe/*.img
|
||||||
|
|
||||||
# Loop tool artifacts
|
# Loop tool artifacts (created in every subdirectory)
|
||||||
*/loop/
|
*/loop/
|
||||||
loop/loop/
|
loop/loop/
|
||||||
loop/loop.log.bak
|
loop/loop.log.bak
|
||||||
@ -73,17 +78,19 @@ loop/loop.log.bak
|
|||||||
# Separate repos nested in tree
|
# Separate repos nested in tree
|
||||||
web/
|
web/
|
||||||
|
|
||||||
# Resilience harness reports contain session cookies.
|
._*
|
||||||
|
|
||||||
|
# Resilience harness reports (generated, contains session cookies)
|
||||||
scripts/resilience/reports/
|
scripts/resilience/reports/
|
||||||
|
|
||||||
# Codex / pnpm / python caches / editor backups
|
# Codex / pnpm / python caches / editor backups
|
||||||
.codex
|
.codex
|
||||||
.codex-target-*/
|
.codex-target-*/
|
||||||
.codex-tmp/
|
.codex-tmp/
|
||||||
.claude/
|
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
*.bak
|
*.bak
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
|
||||||
# Local evidence screenshots; intentional UI screenshots should live under an
|
# Local evidence screenshots; intentional UI screenshots should live under an
|
||||||
# app/docs asset path with a descriptive filename.
|
# app/docs asset path with a descriptive filename.
|
||||||
|
|||||||
BIN
Android/app/debug.keystore
Normal file
BIN
Android/app/debug.keystore
Normal file
Binary file not shown.
@ -197,12 +197,6 @@ private fun injectSafeAreaVars(view: WebView) {
|
|||||||
* the status-bar height: the padded strip shows the page's OWN background
|
* the status-bar height: the padded strip shows the page's OWN background
|
||||||
* (padding is inside the element), so the bar keeps the page colour while
|
* (padding is inside the element), so the bar keeps the page colour while
|
||||||
* content starts below it — the pre-edge-to-edge look, without the black bar.
|
* content starts below it — the pre-edge-to-edge look, without the black bar.
|
||||||
*
|
|
||||||
* Body padding only moves normal-flow content. fixed/sticky elements anchored
|
|
||||||
* at the viewport top (IndeeHub's floating header) stayed glued under the
|
|
||||||
* status bar, so we also push each of those down by the inset — once, marked
|
|
||||||
* via data attribute — and keep a throttled MutationObserver running so
|
|
||||||
* headers an SPA mounts after load get the same treatment.
|
|
||||||
* Idempotent; runs on start (early) and finish (after the app rewrites head). */
|
* Idempotent; runs on start (early) and finish (after the app rewrites head). */
|
||||||
private fun injectTopInset(view: WebView) {
|
private fun injectTopInset(view: WebView) {
|
||||||
val insets = view.rootWindowInsets ?: return
|
val insets = view.rootWindowInsets ?: return
|
||||||
@ -212,7 +206,6 @@ private fun injectTopInset(view: WebView) {
|
|||||||
view.evaluateJavascript(
|
view.evaluateJavascript(
|
||||||
"""
|
"""
|
||||||
(function() {
|
(function() {
|
||||||
var SAT = $sat;
|
|
||||||
var s = document.getElementById('archy-top-inset');
|
var s = document.getElementById('archy-top-inset');
|
||||||
if (!s) {
|
if (!s) {
|
||||||
s = document.createElement('style');
|
s = document.createElement('style');
|
||||||
@ -220,40 +213,7 @@ private fun injectTopInset(view: WebView) {
|
|||||||
(document.head || document.documentElement).appendChild(s);
|
(document.head || document.documentElement).appendChild(s);
|
||||||
}
|
}
|
||||||
s.textContent =
|
s.textContent =
|
||||||
'body{padding-top:' + SAT + 'px!important;box-sizing:border-box!important;}';
|
'body{padding-top:${sat}px!important;box-sizing:border-box!important;}';
|
||||||
function push(el) {
|
|
||||||
if (el.dataset.archyInset) return;
|
|
||||||
var cs = getComputedStyle(el);
|
|
||||||
if (cs.position !== 'fixed' && cs.position !== 'sticky') return;
|
|
||||||
var top = parseFloat(cs.top); // 'auto' -> NaN skips bottom bars
|
|
||||||
if (isNaN(top) || top >= SAT) return;
|
|
||||||
el.style.setProperty('top', (top + SAT) + 'px', 'important');
|
|
||||||
el.dataset.archyInset = '1';
|
|
||||||
}
|
|
||||||
function sweep() {
|
|
||||||
if (!document.body) return;
|
|
||||||
// Fixed/sticky bars live shallow in the tree (portals mount on
|
|
||||||
// body); depth cap keeps the computed-style pass off big lists.
|
|
||||||
var els = document.body.querySelectorAll(
|
|
||||||
'body > *, body > * > *, body > * > * > *, body > * > * > * > *');
|
|
||||||
for (var i = 0; i < els.length; i++) push(els[i]);
|
|
||||||
}
|
|
||||||
sweep();
|
|
||||||
if (!window.__archyInsetObserver) {
|
|
||||||
var queued = false, last = 0;
|
|
||||||
window.__archyInsetObserver = new MutationObserver(function() {
|
|
||||||
if (queued) return;
|
|
||||||
queued = true;
|
|
||||||
var wait = Math.max(0, 250 - (Date.now() - last));
|
|
||||||
setTimeout(function() {
|
|
||||||
queued = false;
|
|
||||||
last = Date.now();
|
|
||||||
sweep();
|
|
||||||
}, wait);
|
|
||||||
});
|
|
||||||
window.__archyInsetObserver.observe(document.documentElement,
|
|
||||||
{ childList: true, subtree: true });
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
null,
|
null,
|
||||||
|
|||||||
@ -1,13 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v1.7.117-alpha (2026-07-27)
|
|
||||||
|
|
||||||
- FIPS startup is more reliable on nodes that have the packaged `fips.service` instead of Archipelago's `archipelago-fips.service`. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.
|
|
||||||
- App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.
|
|
||||||
- Companion WebView safe-area handling now also moves fixed and sticky top bars below the phone status bar, including headers mounted after page load by single-page apps.
|
|
||||||
- Public-source preparation now includes a Nostr Git hosting plan using `ngit`, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.
|
|
||||||
- Node OS release notes for v1.7.117 are still open; add any installer, service, kernel, firewall, or package changes here before cutting the release.
|
|
||||||
|
|
||||||
## v1.7.116-alpha (2026-07-27)
|
## v1.7.116-alpha (2026-07-27)
|
||||||
|
|
||||||
- Nodes no longer get stuck on "server starting up" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.
|
- Nodes no longer get stuck on "server starting up" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
# Code of Conduct
|
|
||||||
|
|
||||||
## Our standard
|
|
||||||
|
|
||||||
Be direct, respectful, and focused on the work. Healthy disagreement is welcome;
|
|
||||||
harassment, personal attacks, and discriminatory language are not.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This code of conduct applies to project repositories, issue trackers, pull
|
|
||||||
requests, documentation, chat, and community spaces connected to Archipelago.
|
|
||||||
|
|
||||||
## Enforcement
|
|
||||||
|
|
||||||
Maintainers may edit, hide, or remove comments and may restrict participation
|
|
||||||
for behavior that makes collaboration unsafe or unproductive.
|
|
||||||
|
|
||||||
Report conduct concerns privately through the repository owner account or the
|
|
||||||
private contact channel listed on the project homepage.
|
|
||||||
191
CONTRIBUTING.md
191
CONTRIBUTING.md
@ -1,100 +1,161 @@
|
|||||||
# Contributing to Archipelago
|
# Contributing to Archipelago
|
||||||
|
|
||||||
This project is preparing for public developer contribution. The highest-value
|
Thank you for your interest in contributing to Archipelago! This document covers the process for contributing code, reporting bugs, and submitting apps.
|
||||||
contributions are focused fixes, tests, app manifests, documentation
|
|
||||||
improvements, and clear bug reports with reproducible evidence.
|
|
||||||
|
|
||||||
## Development setup
|
## Code of Conduct
|
||||||
|
|
||||||
### Frontend
|
Be respectful. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Fork the repository on the project's Gitea instance
|
||||||
|
2. Clone your fork: `git clone <your-fork-url>/archy.git`
|
||||||
|
3. Set up the dev environment (see `docs/developer-guide.md`)
|
||||||
|
4. Create a feature branch: `git checkout -b feature/your-feature`
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Frontend (Vue.js)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd neode-ui
|
cd neode-ui
|
||||||
npm install
|
npm install
|
||||||
npm start
|
npm start # Dev server on :8100
|
||||||
npm run type-check
|
npm run type-check # TypeScript validation
|
||||||
npm test
|
npm run build # Production build
|
||||||
|
npm test # Run tests
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend
|
### Backend (Rust)
|
||||||
|
|
||||||
|
Build on a Linux server (Debian 13), **not** macOS:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd core
|
cargo clippy --all-targets --all-features
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all
|
||||||
cargo clippy --all-targets --all-features -- -D warnings
|
|
||||||
cargo test --all-features
|
cargo test --all-features
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux is required for host integration work involving Podman, systemd,
|
### Deploy to dev server
|
||||||
networking, or image builds. Frontend development works locally with the mock
|
|
||||||
backend.
|
|
||||||
|
|
||||||
## App manifests
|
|
||||||
|
|
||||||
App packages live under `apps/<app-id>/manifest.yml` and use the schema
|
|
||||||
documented in [docs/app-manifest-spec.md](docs/app-manifest-spec.md). Validate
|
|
||||||
before submitting:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml
|
./scripts/deploy-to-target.sh --live
|
||||||
python3 scripts/generate-app-catalog.py
|
|
||||||
python3 scripts/check-app-catalog-drift.py --release --strict
|
|
||||||
```
|
```
|
||||||
|
|
||||||
App submissions must:
|
## Code Style
|
||||||
|
|
||||||
- pin container image versions;
|
### Frontend (TypeScript + Vue)
|
||||||
- avoid hardcoded secrets;
|
|
||||||
- use `security.no_new_privileges: true`;
|
|
||||||
- use `security.readonly_root: true` unless the manifest explains why writable
|
|
||||||
root is required;
|
|
||||||
- request only necessary Linux capabilities;
|
|
||||||
- store durable data under `/var/lib/archipelago/<app-id>/`;
|
|
||||||
- define truthful health checks and launch interfaces for user-facing UIs.
|
|
||||||
|
|
||||||
## Code style
|
- `<script setup lang="ts">` — always Composition API
|
||||||
|
- TypeScript strict mode — no `any`, use `unknown` or proper types
|
||||||
|
- Global CSS classes in `src/style.css` — never inline Tailwind in components
|
||||||
|
- Pinia for state management — focused single-purpose stores
|
||||||
|
- Use `@/api/rpc-client.ts` for RPC calls
|
||||||
|
|
||||||
- Rust: prefer `?` over `unwrap()`/`expect()` in production paths.
|
### Backend (Rust)
|
||||||
- Rust: use `tracing` for structured logs.
|
|
||||||
- TypeScript: avoid `any`; use explicit types or `unknown`.
|
|
||||||
- Vue: prefer `<script setup lang="ts">`.
|
|
||||||
- Keep changes scoped; do not mix drive-by refactors with behavioral changes.
|
|
||||||
- Remove dead code rather than commenting it out.
|
|
||||||
- Add tests for new behavior and regression tests for bug fixes.
|
|
||||||
|
|
||||||
## Pull requests
|
- No `unwrap()` or `expect()` in production code — use `?` operator
|
||||||
|
- `thiserror` for library errors, `anyhow` for application errors
|
||||||
|
- `tracing` for structured logging — never `println!`
|
||||||
|
- Run `cargo clippy` and `cargo fmt` before commits
|
||||||
|
|
||||||
1. Open one focused PR per behavior or documentation change.
|
### General
|
||||||
2. Explain what changed, why it changed, and how it was verified.
|
|
||||||
3. Include screenshots for UI changes.
|
|
||||||
4. Link relevant issues or docs.
|
|
||||||
5. Keep generated catalog changes in sync with manifest changes.
|
|
||||||
|
|
||||||
Suggested commit format:
|
- Functions under 50 lines, single responsibility
|
||||||
|
- Comment WHY not WHAT
|
||||||
|
- Remove dead code — never comment it out
|
||||||
|
- No `TODO`/`FIXME` in commits
|
||||||
|
|
||||||
```text
|
## Commit Format
|
||||||
feat: add backup scheduling
|
|
||||||
fix: reject unsafe manifest volume
|
```
|
||||||
docs: clarify app deployment flow
|
type: description
|
||||||
test: cover catalog drift check
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Reporting bugs
|
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`
|
||||||
|
|
||||||
Include:
|
Examples:
|
||||||
|
- `feat: add backup scheduling to settings page`
|
||||||
|
- `fix: handle WiFi connection timeout gracefully`
|
||||||
|
- `test: add unit tests for RPC client retry logic`
|
||||||
|
|
||||||
- exact version or commit;
|
## Pull Request Process
|
||||||
- host platform and architecture;
|
|
||||||
- steps to reproduce;
|
|
||||||
- expected and actual behavior;
|
|
||||||
- logs from the relevant component;
|
|
||||||
- screenshots for UI issues.
|
|
||||||
|
|
||||||
## Security
|
1. Ensure your branch is up to date with `main`
|
||||||
|
2. All checks must pass: TypeScript, build, tests, clippy
|
||||||
|
3. Include a clear description of what changed and why
|
||||||
|
4. Link any related issues
|
||||||
|
5. Request review from a maintainer
|
||||||
|
|
||||||
Do not report vulnerabilities in public issues. Follow [SECURITY.md](SECURITY.md).
|
### PR Checklist
|
||||||
|
|
||||||
|
- [ ] TypeScript type-check passes (`npm run type-check`)
|
||||||
|
- [ ] Frontend builds (`npm run build`)
|
||||||
|
- [ ] Tests pass (`npm test`)
|
||||||
|
- [ ] Rust clippy clean (`cargo clippy --all-targets --all-features`)
|
||||||
|
- [ ] No new compiler warnings
|
||||||
|
- [ ] Follows code style guidelines above
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
- New features need tests
|
||||||
|
- Bug fixes need a regression test
|
||||||
|
- Frontend: Vitest + Vue Test Utils
|
||||||
|
- Backend: `#[test]` and `#[tokio::test]`
|
||||||
|
- Target: maintain or improve existing coverage
|
||||||
|
|
||||||
|
## Reporting Bugs
|
||||||
|
|
||||||
|
Use the **Bug Report** issue template. Include:
|
||||||
|
|
||||||
|
1. Steps to reproduce
|
||||||
|
2. Expected behavior
|
||||||
|
3. Actual behavior
|
||||||
|
4. System info (hardware, OS version, Archipelago version)
|
||||||
|
5. Screenshots if applicable
|
||||||
|
6. Relevant logs (`journalctl -u archipelago`)
|
||||||
|
|
||||||
|
## Feature Requests
|
||||||
|
|
||||||
|
Use the **Feature Request** issue template. Include:
|
||||||
|
|
||||||
|
1. Problem description
|
||||||
|
2. Proposed solution
|
||||||
|
3. Alternatives considered
|
||||||
|
4. Impact on existing users
|
||||||
|
|
||||||
|
## App Submissions
|
||||||
|
|
||||||
|
To submit an app for the Archipelago marketplace:
|
||||||
|
|
||||||
|
1. Create a manifest following `docs/app-manifest-spec.md`
|
||||||
|
2. Ensure the container image is published to a public registry
|
||||||
|
3. Test on Archipelago hardware (x86_64 and ARM64 if possible)
|
||||||
|
4. Open a PR adding the app to the curated list
|
||||||
|
5. Include: app description, icon, resource requirements, dependencies
|
||||||
|
|
||||||
|
### App Requirements
|
||||||
|
|
||||||
|
- Container must run as non-root (UID > 1000)
|
||||||
|
- `readonly_root: true` unless explicitly justified
|
||||||
|
- Drop all capabilities except those required
|
||||||
|
- `no-new-privileges: true`
|
||||||
|
- Pin specific image versions (no `latest` tag)
|
||||||
|
- No hardcoded secrets
|
||||||
|
|
||||||
|
## Security Disclosure
|
||||||
|
|
||||||
|
**Do NOT open public issues for security vulnerabilities.**
|
||||||
|
|
||||||
|
Email security concerns to the maintainers directly. Include:
|
||||||
|
|
||||||
|
1. Description of the vulnerability
|
||||||
|
2. Steps to reproduce
|
||||||
|
3. Potential impact
|
||||||
|
4. Suggested fix (if any)
|
||||||
|
|
||||||
|
We will acknowledge receipt within 48 hours and provide a timeline for a fix.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
By contributing, you agree that your contribution is licensed under the
|
By contributing, you agree that your contributions will be licensed under the same license as the project.
|
||||||
project's MIT License.
|
|
||||||
|
|||||||
21
LICENSE
21
LICENSE
@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Dorian and the Archipelago Project contributors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
73
NOTICE
73
NOTICE
@ -1,73 +0,0 @@
|
|||||||
# Archipelago — Third-Party Notices
|
|
||||||
|
|
||||||
Archipelago is licensed under the MIT License (see LICENSE).
|
|
||||||
This file lists third-party components included in this repository and its
|
|
||||||
release artifacts, with their licenses and required attributions.
|
|
||||||
|
|
||||||
## Embedded / vendored components
|
|
||||||
|
|
||||||
- **FIPS mesh networking** — https://github.com/jmcorgan/fips
|
|
||||||
Copyright (c) 2026 Johnathan Corgan. MIT License.
|
|
||||||
Used as the embedded mesh VPN in the OS (`fips` daemon, pinned v0.4.1) and
|
|
||||||
compiled into the Android companion app (`Android/rust/archy-fips-core`).
|
|
||||||
|
|
||||||
- **QR Code Generator for JavaScript** — http://www.d-project.com/
|
|
||||||
Copyright (c) 2009 Kazuhiko Arase. MIT License.
|
|
||||||
Vendored at `docker/lnd-ui/qrcode.js` and `docker/electrs-ui/qrcode.js`
|
|
||||||
(original headers preserved).
|
|
||||||
|
|
||||||
- **nostr-rs-relay** — https://github.com/scsibug/nostr-rs-relay — MIT License.
|
|
||||||
Binary extracted into the OS image at `/opt/archipelago/bin/`.
|
|
||||||
|
|
||||||
- **Reticulum (RNS) and LXMF** — https://github.com/markqvist/Reticulum
|
|
||||||
Copyright Mark Qvist. Distributed under the Reticulum License (an MIT-style
|
|
||||||
license with field-of-use restrictions: no use in systems designed to harm
|
|
||||||
human beings, and no use in AI/ML training datasets). The optional
|
|
||||||
`archy-reticulum-daemon` binary bundles RNS 1.3.5 and LXMF 1.0.1. The
|
|
||||||
Reticulum License is NOT an OSI-approved open-source license; it applies
|
|
||||||
only to that optional component, not to Archipelago itself.
|
|
||||||
|
|
||||||
## Fonts
|
|
||||||
|
|
||||||
- **Montserrat** — SIL Open Font License 1.1
|
|
||||||
(`neode-ui/public/assets/fonts/Montserrat/OFL.txt`).
|
|
||||||
- **Open Sans** — Apache License 2.0
|
|
||||||
(`neode-ui/public/assets/fonts/Open_Sans/LICENSE.txt`).
|
|
||||||
|
|
||||||
## Artwork and icons
|
|
||||||
|
|
||||||
- **Mesh device artwork** (`neode-ui/public/assets/img/mesh-devices/`):
|
|
||||||
device illustrations from the Meshtastic project — https://meshtastic.org
|
|
||||||
© Meshtastic contributors, GPL-3.0. Meshtastic® is a registered trademark
|
|
||||||
of Meshtastic LLC. See the ATTRIBUTION.md in that directory.
|
|
||||||
- Some UI icons are derived from **game-icons.net** (CC BY 3.0 — see
|
|
||||||
ATTRIBUTION.md in `neode-ui/public/assets/icon/`) and **pixelarticons**
|
|
||||||
(MIT, https://github.com/halfmage/pixelarticons).
|
|
||||||
- Third-party application logos under `neode-ui/public/assets/img/app-icons/`
|
|
||||||
and `service-icons/` are trademarks of their respective owners, used solely
|
|
||||||
to identify the corresponding applications. No endorsement is implied.
|
|
||||||
|
|
||||||
## Original media
|
|
||||||
|
|
||||||
All demo content (music, photos, posters in `demo/`), UI sound effects,
|
|
||||||
background images, and intro video in `neode-ui/public/assets/` are original
|
|
||||||
works created and owned by the Archipelago project author, released with the
|
|
||||||
project. The welcome voice line (`welcome-noderunner.mp3`) was generated with
|
|
||||||
ElevenLabs TTS under a commercial-use plan.
|
|
||||||
|
|
||||||
## Redistributed software (ISO and container registry)
|
|
||||||
|
|
||||||
The Archipelago OS image is based on Debian and redistributes Debian packages
|
|
||||||
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs
|
|
||||||
required for hardware support); per-package license texts are preserved at
|
|
||||||
`/usr/share/doc/*/copyright` in the installed system, and corresponding source
|
|
||||||
is available via Debian (https://snapshot.debian.org) as referenced in each
|
|
||||||
release's notes. Container images offered through the app catalog and mirror
|
|
||||||
registry remain under their upstream licenses (including GPL/AGPL software
|
|
||||||
such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich,
|
|
||||||
Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in
|
|
||||||
the app catalog. The modified mempool-frontend image is built from
|
|
||||||
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
|
|
||||||
|
|
||||||
Full per-crate and per-package license inventories for release binaries are
|
|
||||||
generated at build time (see THIRD-PARTY-LICENSES files in release artifacts).
|
|
||||||
229
README.md
229
README.md
@ -1,11 +1,8 @@
|
|||||||
# Archipelago
|
# Archipelago
|
||||||
|
|
||||||
> Self-sovereign Bitcoin node OS and manifest-driven app platform.
|
> Self-Sovereign Bitcoin Node OS
|
||||||
|
|
||||||
Archipelago is a bootable personal server OS for Bitcoin infrastructure,
|
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, mesh communication, and decentralized identity through a glassmorphism web UI.
|
||||||
self-hosted apps, mesh communication, decentralized identity, and federation.
|
|
||||||
Apps are packaged as declarative `manifest.yml` files and run as rootless
|
|
||||||
Podman containers managed by the Rust backend.
|
|
||||||
|
|
||||||
[](https://www.debian.org/)
|
[](https://www.debian.org/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
@ -13,101 +10,193 @@ Podman containers managed by the Rust backend.
|
|||||||
[](https://vuejs.org/)
|
[](https://vuejs.org/)
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
## What is here
|
## Philosophy
|
||||||
|
|
||||||
- `core/` - Rust workspace: backend API, container runtime, security, OpenWrt
|
Archipelago is being built as a **developer-ready app platform**, not a fixed appliance:
|
||||||
helpers, and performance/resource management.
|
|
||||||
- `neode-ui/` - Vue 3 + TypeScript frontend.
|
|
||||||
- `apps/` - app manifests and custom app container sources.
|
|
||||||
- `docker/` - supporting container build contexts for UI companion surfaces.
|
|
||||||
- `image-recipe/` - bootable image/ISO build inputs.
|
|
||||||
- `Android/` - Android companion app.
|
|
||||||
- `scripts/` - development, release, deployment, and validation tooling.
|
|
||||||
- `docs/` - architecture, app packaging, operations, API, and roadmap docs.
|
|
||||||
|
|
||||||
## Platform model
|
- **Manifest-driven apps.** Every app is declared in a single `manifest.yml` — image, ports, volumes, secrets, health checks, security policy. The orchestrator owns the entire lifecycle; there is no per-app installer code and no host-level provisioning.
|
||||||
|
- **Signed distribution.** App manifests ship inside an Ed25519-signed catalog verified against a pinned release-root key, not as loose files on disk. OTA release manifests are signed the same way.
|
||||||
|
- **Decentralized marketplace.** Third-party developers publish apps via Nostr-based discovery (NIP-78) with DID-signed manifests and federation-weighted trust scoring — no gatekept central store.
|
||||||
|
- **Rootless and secure by default.** Rootless Podman only. Read-only root, no-new-privileges, capability allow-list, secrets materialised 0600 and never logged. Never rootful, never a Docker socket mount.
|
||||||
|
- **100%-uptime-capable.** Every container is a systemd Quadlet unit under `user.slice` that survives backend restarts; a level-triggered reconciler self-heals drift every 30 seconds; migrations never destroy data.
|
||||||
|
|
||||||
Archipelago is built as a developer-ready app platform, not a fixed appliance:
|
## Features
|
||||||
|
|
||||||
- Apps are declared in `apps/<app-id>/manifest.yml`.
|
### Bitcoin Infrastructure
|
||||||
- The Rust parser in `core/container/src/manifest.rs` is the canonical schema.
|
- **Bitcoin Core and Bitcoin Knots** full nodes with per-app version pinning and bulletproof version switching, automatic prune/full mode based on disk size
|
||||||
- The orchestrator compiles manifests to rootless Podman/Quadlet runtime state.
|
- **LND** and **Core Lightning** with channel management
|
||||||
- App data lives under `/var/lib/archipelago/<app-id>/`.
|
- **ElectrumX** Electrum server for wallet connectivity
|
||||||
- Secrets are generated or read from `/var/lib/archipelago/secrets/` and
|
- **BTCPay Server** for accepting Bitcoin payments
|
||||||
injected through Podman secrets rather than static environment values.
|
- **Mempool** block explorer and fee estimator
|
||||||
- Release and app catalogs are signed and verified against a pinned trust
|
- **Fedimint** federation guardian, gateway, and client — plus Cashu ecash wallet support
|
||||||
anchor.
|
|
||||||
|
|
||||||
Start with:
|
### Self-Hosted Apps (50+)
|
||||||
|
Storage (FileBrowser, Immich, Nextcloud), Productivity (Vaultwarden), Media (Jellyfin, PhotoPrism, IndeeHub), Search (SearXNG), Network (NetBird, Tailscale), Home (Home Assistant), Nostr (nostr-rs-relay, strfry), Dev/Ops (Gitea, Grafana, Portainer, Uptime Kuma), and more — 27 curated in the store UI, 50+ packaged as manifests.
|
||||||
|
|
||||||
- [Architecture](docs/architecture.md)
|
### Mesh Networking (tri-protocol)
|
||||||
- [Developer Guide](docs/developer-guide.md)
|
- **Meshtastic**, **MeshCore**, and **Reticulum (RNS/LXMF)** LoRa transports behind one mesh chat UI
|
||||||
- [App Developer Guide](docs/app-developer-guide.md)
|
- End-to-end encryption with X3DH key agreement + double-ratchet
|
||||||
- [App Manifest Spec](docs/app-manifest-spec.md)
|
- RNode radio support with an OS-level `archy-rnodeconf` tool; interop verified against Sideband
|
||||||
- [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md)
|
- Image/voice attachments, mesh AI assistant (`!ai`), Bitcoin balance relay over mesh
|
||||||
- [Operations Runbook](docs/operations-runbook.md)
|
|
||||||
- [Troubleshooting](docs/troubleshooting.md)
|
|
||||||
|
|
||||||
## Quick start
|
### Decentralized Identity
|
||||||
|
- Ed25519 node identity with DID Documents (did:key)
|
||||||
|
- Multi-identity management (Personal/Business/Anonymous)
|
||||||
|
- W3C Verifiable Credentials issuance and verification
|
||||||
|
- Nostr integration: NIP-33 node discovery, NIP-44/NIP-04 encryption, NIP-07 signer bridge for iframe apps, relay hosting
|
||||||
|
- Decentralized Web Node (DWN) record sync between federated nodes over Tor
|
||||||
|
|
||||||
### Frontend
|
### Multi-Node Federation
|
||||||
|
- Invite-based node joining over Tor hidden services
|
||||||
|
- Trust levels (Trusted/Verified/Untrusted) with DID-based auth
|
||||||
|
- State sync and app deployment across federated nodes
|
||||||
|
- File sharing with access controls (free/peers-only/paid via Lightning, on-chain, or ecash)
|
||||||
|
|
||||||
|
### System Updates
|
||||||
|
- OTA updates from a self-hosted Gitea release server, Ed25519-signature-verified against a pinned release-root key
|
||||||
|
- Resumable downloads, automatic pre-update backup, rollback with a post-update self-verify window
|
||||||
|
- Manual, scheduled-check, and auto-apply modes (auto-apply refuses unsigned manifests)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Argon2id password hashing (transparent upgrade from legacy hashes), ChaCha20-Poly1305 encrypted secrets at rest
|
||||||
|
- Rootless Podman: read-only root, cap-drop ALL with a reviewed allow-list, no-new-privileges
|
||||||
|
- Signed release manifests and signed app catalog (Ed25519, pinned trust anchor)
|
||||||
|
- TOTP two-factor authentication, per-endpoint rate limiting, CSRF protection
|
||||||
|
- AppArmor profiles for container confinement; Tor hidden services for inter-node traffic
|
||||||
|
- Independent security audit of an early version archived in [`docs/archive/`](docs/archive/security-code-audit-2026-03.md); top findings since remediated
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
**Done**
|
||||||
|
- Single-node production gate **green** — install / stop / start / restart / reinstall / reboot-survive / uninstall, 5 consecutive full runs with zero failures on real hardware
|
||||||
|
- Quadlet migration validated (all backends as `user.slice` services on the canary node)
|
||||||
|
- Release signing ceremony completed — release-root key pinned, catalog and OTA manifests signed
|
||||||
|
- Reticulum third mesh transport (real-RF LoRa gates passed), Bitcoin Core/Knots multi-version switching, decentralized marketplace backend, public demo
|
||||||
|
|
||||||
|
**In progress**
|
||||||
|
- Multinode pass: the same production gate across the whole test fleet ([`docs/multinode-testing-plan.md`](docs/multinode-testing-plan.md))
|
||||||
|
- Quadlet default flip fleet-wide + container-flapping elimination
|
||||||
|
- 1.8.0 release hardening tail ([`docs/1.8.0-RELEASE-HARDENING-PLAN.md`](docs/1.8.0-RELEASE-HARDENING-PLAN.md)): OTA upgrade soak on real hardware, ISO/image hardening (per-device keys, no default creds, signed ISO)
|
||||||
|
|
||||||
|
**Planned**
|
||||||
|
- Developer CLI (`archy app validate/render/install/test`) to open third-party app publishing
|
||||||
|
- External marketplace trust UX + publishing tooling ([`docs/marketplace-protocol.md`](docs/marketplace-protocol.md))
|
||||||
|
- DHT/P2P distribution of releases and app images ([`docs/dht-distribution-design.md`](docs/dht-distribution-design.md))
|
||||||
|
- P2P encrypted voice/video over Tor, dual-ecash (Fedimint + Cashu) phases, paid streaming, hardware signer support
|
||||||
|
|
||||||
|
The live, priority-ordered task list is [`docs/UNIFIED-TASK-TRACKER.md`](docs/UNIFIED-TASK-TRACKER.md); the full narrative plan is [`docs/PRODUCTION-MASTER-PLAN.md`](docs/PRODUCTION-MASTER-PLAN.md).
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Install from ISO
|
||||||
|
|
||||||
|
1. Build or download the ISO for your architecture (x86_64 or ARM64) — see [`image-recipe/`](image-recipe/)
|
||||||
|
2. Flash to USB drive with Balena Etcher or `dd`
|
||||||
|
3. Boot from USB on target hardware and follow the automated installer
|
||||||
|
4. Access the web UI at `http://<device-ip>`
|
||||||
|
5. Set your password and complete the onboarding wizard (seed backup, DID identity)
|
||||||
|
|
||||||
|
### Supported Hardware
|
||||||
|
|
||||||
|
| Platform | Examples | Minimum |
|
||||||
|
|----------|----------|---------|
|
||||||
|
| **x86_64** | Intel NUC, mini PCs, any 64-bit PC | 4GB RAM, 32GB storage |
|
||||||
|
| **ARM64** | Raspberry Pi 5, ARM64 SBCs | 4GB RAM, 32GB storage |
|
||||||
|
|
||||||
|
**Recommended**: 8GB+ RAM, 1TB+ NVMe SSD (for a full Bitcoin node). Optional: an RNode-compatible LoRa radio for mesh networking.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- macOS or Linux for frontend development
|
||||||
|
- Linux dev server (Debian 13) for backend builds — **never build Rust on macOS for Linux**
|
||||||
|
- Node.js 20+, Rust stable toolchain
|
||||||
|
|
||||||
|
### Frontend Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd neode-ui
|
cd neode-ui
|
||||||
npm install
|
npm install
|
||||||
npm start
|
npm start # Dev server on http://localhost:8100 (mock backend on :5959)
|
||||||
|
npm run type-check # TypeScript validation
|
||||||
|
npm run build # Production build → web/dist/neode-ui/
|
||||||
```
|
```
|
||||||
|
|
||||||
The dev UI runs at `http://localhost:8100` with a mock backend on `:5959`.
|
### Backend Development
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd core
|
cd core # Rust workspace root (no Cargo.toml at repo root)
|
||||||
cargo build
|
cargo build
|
||||||
cargo test --all-features
|
cargo test
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux is the supported backend runtime and release-build target. macOS is fine
|
### Deploy to a Test Node
|
||||||
for frontend work and many Rust compile/test loops, but host integration tests
|
|
||||||
that touch Podman, systemd, networking, or image build paths require Linux.
|
|
||||||
|
|
||||||
### App manifests
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/validate-app-manifest.sh apps/filebrowser/manifest.yml
|
./scripts/deploy-to-target.sh --live # Deploy to primary dev server
|
||||||
python3 scripts/generate-app-catalog.py
|
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
|
||||||
python3 scripts/check-app-catalog-drift.py --release --strict
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`scripts/generate-app-catalog.py` requires Python with PyYAML installed.
|
### Release (tarball-only)
|
||||||
|
|
||||||
## Documentation map
|
Releases ship as a backend binary and a frontend tarball referenced by
|
||||||
|
`releases/manifest.json`, published to the self-hosted Gitea release server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/create-release.sh 1.2.3
|
||||||
|
git push origin main --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Debian 13 (Trixie)
|
||||||
|
├── Rootless Podman — every app a systemd Quadlet unit under user.slice
|
||||||
|
├── Nginx (reverse proxy, security headers, rate limiting)
|
||||||
|
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678, ~380 RPC methods)
|
||||||
|
│ ├── core/archipelago/ — API, orchestrator + reconciler, mesh, identity,
|
||||||
|
│ │ federation, wallet, updates, marketplace
|
||||||
|
│ ├── core/container/ — Podman client, manifest schema, Quadlet compiler,
|
||||||
|
│ │ health monitor, signed app catalog
|
||||||
|
│ ├── core/security/ — AppArmor/seccomp policy, secrets manager
|
||||||
|
│ ├── core/openwrt/ — TollGate gateway provisioning (SSH/UCI)
|
||||||
|
│ └── core/performance/ — resource limits
|
||||||
|
├── Vue 3 Frontend (Composition API + TypeScript strict + Pinia + Tailwind, PWA)
|
||||||
|
│ └── Three UI modes (Pro/Easy/Chat) + gamepad navigation + i18n
|
||||||
|
├── Reticulum daemon (supervised Python/PyInstaller, one per LoRa radio)
|
||||||
|
└── System Tor (hidden services, SOCKS5 proxy)
|
||||||
|
```
|
||||||
|
|
||||||
|
~117,000 lines of Rust | ~69,000 lines of TypeScript/Vue | 51 packaged apps | Android companion app
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
| Doc | Purpose |
|
| Doc | Purpose |
|
||||||
|-----|---------|
|
|-----|---------|
|
||||||
| [Architecture](docs/architecture.md) | System layers, crates, data paths, security model |
|
| [Architecture](docs/architecture.md) | System design, crate map, data paths |
|
||||||
| [Developer Guide](docs/developer-guide.md) | Local setup, code workflow, testing |
|
| [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions |
|
||||||
| [API Reference](docs/api-reference.md) | JSON-RPC API overview |
|
| [API Reference](docs/api-reference.md) | RPC endpoint reference |
|
||||||
| [App Developer Guide](docs/app-developer-guide.md) | How to package and test apps |
|
| [App Developer Guide](docs/app-developer-guide.md) | Building and publishing apps |
|
||||||
| [App Manifest Spec](docs/app-manifest-spec.md) | Manifest schema and validation rules |
|
| [App Manifest Spec](docs/app-manifest-spec.md) | The `manifest.yml` schema |
|
||||||
| [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) | ngit/NIP-34 contribution workflow and maintainer model |
|
| [User Walkthrough](docs/user-walkthrough.md) | End-user installation and usage guide |
|
||||||
| [Apps README](apps/README.md) | Packaged app catalog overview |
|
| [Troubleshooting](docs/troubleshooting.md) | Diagnostic scenarios and solutions |
|
||||||
| [Image Recipe](image-recipe/README.md) | Bootable image build flow |
|
| [Operations Runbook](docs/operations-runbook.md) | Ops commands and emergency recovery |
|
||||||
| [Operations Runbook](docs/operations-runbook.md) | Production operations and recovery |
|
| [Production Master Plan](docs/PRODUCTION-MASTER-PLAN.md) | North star and workstream narrative |
|
||||||
| [Open Source Readiness](docs/OPEN_SOURCE_READINESS.md) | Public-release cleanup checklist |
|
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Live, priority-ordered open items |
|
||||||
| [Roadmap](docs/ROADMAP.md) | Shipped, in-progress, and planned work |
|
| [Test Gate](tests/lifecycle/TESTING.md) | Production lifecycle test gate (definition of done) |
|
||||||
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Launch hardening task list |
|
| [Archive](docs/archive/) | Historical audits, session logs, shipped designs |
|
||||||
| [Archive](docs/archive/) | Historical plans, audits, and handoffs |
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. For
|
1. Fork the repository
|
||||||
security issues, follow [SECURITY.md](SECURITY.md) and do not open a public
|
2. Create a feature branch (`feature/description`)
|
||||||
issue.
|
3. Follow the coding standards in [CONTRIBUTING.md](CONTRIBUTING.md) and [CLAUDE.md](CLAUDE.md)
|
||||||
|
4. Submit a pull request
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Archipelago is licensed under the [MIT License](LICENSE). Third-party notices
|
[MIT License](LICENSE)
|
||||||
are listed in [NOTICE](NOTICE) and generated license inventories in component
|
|
||||||
release artifacts.
|
## Acknowledgments
|
||||||
|
|
||||||
|
Built with: [Rust](https://www.rust-lang.org/), [Vue.js](https://vuejs.org/), [Podman](https://podman.io/), [Bitcoin Core](https://bitcoin.org/), [LND](https://lightning.engineering/), [Reticulum](https://reticulum.network/), [Debian](https://www.debian.org/)
|
||||||
|
|||||||
38
SECURITY.md
38
SECURITY.md
@ -1,38 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
## Reporting vulnerabilities
|
|
||||||
|
|
||||||
Please do not open a public issue for a security vulnerability.
|
|
||||||
|
|
||||||
Until a dedicated security intake address is published, report privately to the
|
|
||||||
project maintainer through the repository owner account or the private contact
|
|
||||||
channel listed on the project homepage.
|
|
||||||
|
|
||||||
Include:
|
|
||||||
|
|
||||||
- affected commit, version, or release;
|
|
||||||
- affected component;
|
|
||||||
- reproduction steps;
|
|
||||||
- expected impact;
|
|
||||||
- logs, proof of concept, or packet captures when relevant;
|
|
||||||
- whether the issue is already public.
|
|
||||||
|
|
||||||
We aim to acknowledge credible reports within 48 hours and coordinate fixes
|
|
||||||
before public disclosure.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
Security-sensitive areas include:
|
|
||||||
|
|
||||||
- authentication, session handling, CSRF, and rate limiting;
|
|
||||||
- release and app-catalog signature verification;
|
|
||||||
- container manifest validation and runtime compilation;
|
|
||||||
- Podman/Quadlet isolation, capabilities, volumes, and secret injection;
|
|
||||||
- backup encryption and key derivation;
|
|
||||||
- federation, Tor, Nostr, mesh, DID, and credential flows;
|
|
||||||
- Android companion pairing and device-token handling.
|
|
||||||
|
|
||||||
## Supported versions
|
|
||||||
|
|
||||||
Archipelago is currently pre-1.0 alpha software. Security fixes target the
|
|
||||||
current `main` branch and the latest published alpha release.
|
|
||||||
@ -76,17 +76,7 @@ podman run -p 18084:8080 \
|
|||||||
|
|
||||||
## Integration Checklist
|
## Integration Checklist
|
||||||
|
|
||||||
Adding a new app requires updates in multiple places:
|
Adding a new app requires updates in multiple places. See the full checklist in [CLAUDE.md](../CLAUDE.md) under "App Integration Checklist".
|
||||||
|
|
||||||
- add `apps/<app-id>/manifest.yml`;
|
|
||||||
- add a Dockerfile and source directory only when the app is built locally;
|
|
||||||
- choose non-conflicting ports from [PORTS.md](./PORTS.md);
|
|
||||||
- declare `interfaces.main` for user-facing web UIs;
|
|
||||||
- declare generated secrets instead of hardcoding credentials;
|
|
||||||
- run `./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml`;
|
|
||||||
- regenerate catalogs with `python3 scripts/generate-app-catalog.py`;
|
|
||||||
- verify drift with `python3 scripts/check-app-catalog-drift.py --release --strict`;
|
|
||||||
- test install, launch, stop, start, restart, uninstall, and reinstall.
|
|
||||||
|
|
||||||
## Port Assignments
|
## Port Assignments
|
||||||
|
|
||||||
|
|||||||
33
core/.env.production
Normal file
33
core/.env.production
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Archipelago Production Configuration
|
||||||
|
# This file is bundled with the macOS app
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
ARCHIPELAGO_HOST=127.0.0.1
|
||||||
|
ARCHIPELAGO_PORT=8100
|
||||||
|
ARCHIPELAGO_BACKEND_PORT=3030
|
||||||
|
|
||||||
|
# Data Directories (relative to ~/Library/Application Support/Archipelago)
|
||||||
|
ARCHIPELAGO_DATA_DIR=data
|
||||||
|
ARCHIPELAGO_LOG_DIR=logs
|
||||||
|
|
||||||
|
# Frontend Configuration
|
||||||
|
ARCHIPELAGO_FRONTEND_DIR=frontend
|
||||||
|
|
||||||
|
# Docker UI Configuration
|
||||||
|
ARCHIPELAGO_DOCKER_UI_DIR=docker-ui
|
||||||
|
|
||||||
|
# Security
|
||||||
|
ARCHIPELAGO_SESSION_SECRET=CHANGE_ME_ON_FIRST_RUN
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
RUST_LOG=info
|
||||||
|
|
||||||
|
# Production Mode
|
||||||
|
NODE_ENV=production
|
||||||
|
ARCHIPELAGO_MODE=production
|
||||||
|
|
||||||
|
# Docker Configuration
|
||||||
|
DOCKER_HOST=unix:///var/run/docker.sock
|
||||||
|
|
||||||
|
# Disable External API Calls in Production
|
||||||
|
ARCHIPELAGO_DISABLE_EXTERNAL_APIS=true
|
||||||
@ -1,656 +0,0 @@
|
|||||||
# Third-Party Rust Crate Licenses — Archipelago core
|
|
||||||
|
|
||||||
Generated from `cargo metadata` (all features) on 2026-07-23. 649 external crates.
|
|
||||||
Full license texts ship with release artifacts (cargo-about; see docs/LICENSE-COMPLIANCE-AUDIT.md).
|
|
||||||
|
|
||||||
| Crate | Version | License | Source |
|
|
||||||
|---|---|---|---|
|
|
||||||
| adler2 | 2.0.1 | 0BSD OR MIT OR Apache-2.0 | https://github.com/oyvindln/adler2 |
|
|
||||||
| aead | 0.5.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
|
||||||
| aes | 0.8.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-ciphers |
|
|
||||||
| aes-gcm | 0.10.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/AEADs |
|
|
||||||
| ahash | 0.7.8 | MIT OR Apache-2.0 | https://github.com/tkaitchuck/ahash |
|
|
||||||
| aho-corasick | 1.1.4 | Unlicense OR MIT | https://github.com/BurntSushi/aho-corasick |
|
|
||||||
| allocator-api2 | 0.2.21 | MIT OR Apache-2.0 | https://github.com/zakarumych/allocator-api2 |
|
|
||||||
| android_system_properties | 0.1.5 | MIT/Apache-2.0 | https://github.com/nical/android_system_properties |
|
|
||||||
| anyhow | 1.0.100 | MIT OR Apache-2.0 | https://github.com/dtolnay/anyhow |
|
|
||||||
| arc-swap | 1.9.1 | MIT OR Apache-2.0 | https://github.com/vorner/arc-swap |
|
|
||||||
| argon2 | 0.5.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/argon2 |
|
|
||||||
| arrayref | 0.3.9 | BSD-2-Clause | https://github.com/droundy/arrayref |
|
|
||||||
| arrayvec | 0.7.6 | MIT OR Apache-2.0 | https://github.com/bluss/arrayvec |
|
|
||||||
| asn1-rs | 0.7.2 | MIT OR Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
|
|
||||||
| asn1-rs-derive | 0.6.0 | MIT OR Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
|
|
||||||
| asn1-rs-impl | 0.2.0 | MIT/Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
|
|
||||||
| async-trait | 0.1.89 | MIT OR Apache-2.0 | https://github.com/dtolnay/async-trait |
|
|
||||||
| async-utility | 0.3.1 | MIT | https://github.com/yukibtc/async-utility.git |
|
|
||||||
| async-wsocket | 0.13.1 | MIT | https://github.com/yukibtc/async-wsocket.git |
|
|
||||||
| async_io_stream | 0.3.3 | Unlicense | https://github.com/najamelan/async_io_stream |
|
|
||||||
| atomic-destructor | 0.3.0 | MIT | https://github.com/yukibtc/atomic-destructor.git |
|
|
||||||
| atomic-polyfill | 1.0.3 | MIT OR Apache-2.0 | https://github.com/embassy-rs/atomic-polyfill |
|
|
||||||
| atomic-waker | 1.1.2 | Apache-2.0 OR MIT | https://github.com/smol-rs/atomic-waker |
|
|
||||||
| attohttpc | 0.30.1 | MPL-2.0 | https://github.com/sbstp/attohttpc |
|
|
||||||
| autocfg | 1.5.0 | Apache-2.0 OR MIT | https://github.com/cuviper/autocfg |
|
|
||||||
| backon | 1.6.0 | Apache-2.0 | https://github.com/Xuanwo/backon |
|
|
||||||
| bao-tree | 0.16.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/bao-tree |
|
|
||||||
| base16ct | 1.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| base32 | 0.5.1 | MIT OR Apache-2.0 | https://github.com/andreasots/base32 |
|
|
||||||
| base58ck | 0.1.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
|
||||||
| base64 | 0.21.7 | MIT OR Apache-2.0 | https://github.com/marshallpierce/rust-base64 |
|
|
||||||
| base64 | 0.22.1 | MIT OR Apache-2.0 | https://github.com/marshallpierce/rust-base64 |
|
|
||||||
| base64ct | 1.8.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| bcrypt | 0.15.1 | MIT | https://github.com/Keats/rust-bcrypt |
|
|
||||||
| bech32 | 0.11.1 | MIT | https://github.com/rust-bitcoin/rust-bech32 |
|
|
||||||
| binary-merge | 0.1.2 | MIT OR Apache-2.0 | https://github.com/rklaehn/binary-merge |
|
|
||||||
| bip39 | 2.1.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bip39/ |
|
|
||||||
| bit-vec | 0.9.1 | Apache-2.0 OR MIT | https://github.com/contain-rs/bit-vec |
|
|
||||||
| bitcoin | 0.32.5 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
|
||||||
| bitcoin-internals | 0.2.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
|
||||||
| bitcoin-internals | 0.3.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
|
||||||
| bitcoin-io | 0.1.4 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
|
|
||||||
| bitcoin-units | 0.1.2 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
|
||||||
| bitcoin_hashes | 0.13.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
|
|
||||||
| bitcoin_hashes | 0.14.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
|
|
||||||
| bitflags | 1.3.2 | MIT/Apache-2.0 | https://github.com/bitflags/bitflags |
|
|
||||||
| bitflags | 2.13.0 | MIT OR Apache-2.0 | https://github.com/bitflags/bitflags |
|
|
||||||
| blake2 | 0.10.6 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
|
||||||
| blake3 | 1.8.5 | CC0-1.0 OR Apache-2.0 OR Apache-2.0 WITH LLVM-exception | https://github.com/BLAKE3-team/BLAKE3 |
|
|
||||||
| block-buffer | 0.10.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
|
||||||
| block-buffer | 0.12.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
|
||||||
| block-padding | 0.3.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
|
||||||
| block2 | 0.6.2 | MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| blowfish | 0.9.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-ciphers |
|
|
||||||
| bs58 | 0.5.1 | MIT/Apache-2.0 | https://github.com/Nullus157/bs58-rs |
|
|
||||||
| bumpalo | 3.19.1 | MIT OR Apache-2.0 | https://github.com/fitzgen/bumpalo |
|
|
||||||
| bytemuck | 1.25.0 | Zlib OR Apache-2.0 OR MIT | https://github.com/Lokathor/bytemuck |
|
|
||||||
| byteorder | 1.5.0 | Unlicense OR MIT | https://github.com/BurntSushi/byteorder |
|
|
||||||
| byteorder-lite | 0.1.0 | Unlicense OR MIT | https://github.com/image-rs/byteorder-lite |
|
|
||||||
| bytes | 1.11.0 | MIT | https://github.com/tokio-rs/bytes |
|
|
||||||
| cbc | 0.1.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-modes |
|
|
||||||
| cc | 1.2.54 | MIT OR Apache-2.0 | https://github.com/rust-lang/cc-rs |
|
|
||||||
| cesu8 | 1.1.0 | Apache-2.0/MIT | https://github.com/emk/cesu8-rs |
|
|
||||||
| cfg-if | 1.0.4 | MIT OR Apache-2.0 | https://github.com/rust-lang/cfg-if |
|
|
||||||
| cfg_aliases | 0.2.1 | MIT | https://github.com/katharostech/cfg_aliases |
|
|
||||||
| chacha20 | 0.10.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/stream-ciphers |
|
|
||||||
| chacha20 | 0.9.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/stream-ciphers |
|
|
||||||
| chacha20poly1305 | 0.10.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/AEADs/tree/master/chacha20poly1305 |
|
|
||||||
| chrono | 0.4.43 | MIT OR Apache-2.0 | https://github.com/chronotope/chrono |
|
|
||||||
| ciborium | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
|
|
||||||
| ciborium-io | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
|
|
||||||
| ciborium-ll | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
|
|
||||||
| cipher | 0.4.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
|
||||||
| cmov | 0.5.4 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
|
|
||||||
| cobs | 0.3.0 | MIT OR Apache-2.0 | https://github.com/jamesmunns/cobs.rs |
|
|
||||||
| combine | 4.6.7 | MIT | https://github.com/Marwes/combine |
|
|
||||||
| const-oid | 0.10.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| const-oid | 0.9.6 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/const-oid |
|
|
||||||
| constant_time_eq | 0.3.1 | CC0-1.0 OR MIT-0 OR Apache-2.0 | https://github.com/cesarb/constant_time_eq |
|
|
||||||
| constant_time_eq | 0.4.2 | CC0-1.0 OR MIT-0 OR Apache-2.0 | https://github.com/cesarb/constant_time_eq |
|
|
||||||
| convert_case | 0.10.0 | MIT | https://github.com/rutrum/convert-case |
|
|
||||||
| cordyceps | 0.3.4 | MIT | https://github.com/hawkw/mycelium |
|
|
||||||
| core-foundation | 0.10.1 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
|
|
||||||
| core-foundation | 0.9.4 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
|
|
||||||
| core-foundation-sys | 0.8.7 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
|
|
||||||
| cpufeatures | 0.2.17 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
|
||||||
| cpufeatures | 0.3.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
|
||||||
| crc | 3.4.0 | MIT OR Apache-2.0 | https://github.com/mrhooray/crc-rs.git |
|
|
||||||
| crc-catalog | 2.4.0 | MIT OR Apache-2.0 | https://github.com/akhilles/crc-catalog.git |
|
|
||||||
| crc32fast | 1.5.0 | MIT OR Apache-2.0 | https://github.com/srijs/rust-crc32fast |
|
|
||||||
| critical-section | 1.2.0 | MIT OR Apache-2.0 | https://github.com/rust-embedded/critical-section |
|
|
||||||
| crossbeam-channel | 0.5.15 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
|
|
||||||
| crossbeam-epoch | 0.9.18 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
|
|
||||||
| crossbeam-utils | 0.8.21 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
|
|
||||||
| crunchy | 0.2.4 | MIT | https://github.com/eira-fransham/crunchy |
|
|
||||||
| crypto-common | 0.1.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
|
||||||
| crypto-common | 0.2.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
|
||||||
| ctr | 0.9.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-modes |
|
|
||||||
| ctutils | 0.4.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
|
|
||||||
| curve25519-dalek | 4.1.3 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek |
|
|
||||||
| curve25519-dalek | 5.0.0-rc.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek |
|
|
||||||
| curve25519-dalek-derive | 0.1.1 | MIT/Apache-2.0 | https://github.com/dalek-cryptography/curve25519-dalek |
|
|
||||||
| darling | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
|
|
||||||
| darling_core | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
|
|
||||||
| darling_macro | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
|
|
||||||
| data-encoding | 2.11.0 | MIT | https://github.com/ia0/data-encoding |
|
|
||||||
| data-encoding-macro | 0.1.20 | MIT | https://github.com/ia0/data-encoding |
|
|
||||||
| data-encoding-macro-internal | 0.1.18 | MIT | https://github.com/ia0/data-encoding |
|
|
||||||
| der | 0.7.10 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/der |
|
|
||||||
| der | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| der-parser | 10.0.0 | MIT OR Apache-2.0 | https://github.com/rusticata/der-parser.git |
|
|
||||||
| deranged | 0.5.8 | MIT OR Apache-2.0 | https://github.com/jhpratt/deranged |
|
|
||||||
| derive_builder | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
|
|
||||||
| derive_builder_core | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
|
|
||||||
| derive_builder_macro | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
|
|
||||||
| derive_more | 2.1.1 | MIT | https://github.com/JelteF/derive_more |
|
|
||||||
| derive_more-impl | 2.1.1 | MIT | https://github.com/JelteF/derive_more |
|
|
||||||
| diatomic-waker | 0.2.3 | MIT OR Apache-2.0 | https://github.com/asynchronics/diatomic-waker |
|
|
||||||
| digest | 0.10.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
|
||||||
| digest | 0.11.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
|
||||||
| dispatch2 | 0.3.1 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| displaydoc | 0.2.5 | MIT OR Apache-2.0 | https://github.com/yaahc/displaydoc |
|
|
||||||
| dlopen2 | 0.8.2 | MIT | https://github.com/OpenByteDev/dlopen2 |
|
|
||||||
| ed25519 | 2.2.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/signatures/tree/master/ed25519 |
|
|
||||||
| ed25519 | 3.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/signatures |
|
|
||||||
| ed25519-dalek | 2.2.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/ed25519-dalek |
|
|
||||||
| ed25519-dalek | 3.0.0-rc.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/ed25519-dalek |
|
|
||||||
| either | 1.15.0 | MIT OR Apache-2.0 | https://github.com/rayon-rs/either |
|
|
||||||
| embedded-io | 0.4.0 | MIT OR Apache-2.0 | https://github.com/embassy-rs/embedded-io |
|
|
||||||
| embedded-io | 0.6.1 | MIT OR Apache-2.0 | https://github.com/rust-embedded/embedded-hal |
|
|
||||||
| encoding_rs | 0.8.35 | (Apache-2.0 OR MIT) AND BSD-3-Clause | https://github.com/hsivonen/encoding_rs |
|
|
||||||
| enum-assoc | 1.3.0 | MIT OR Apache-2.0 | https://github.com/Eolu/enum-assoc |
|
|
||||||
| env_logger | 0.10.2 | MIT OR Apache-2.0 | https://github.com/rust-cli/env_logger |
|
|
||||||
| equivalent | 1.0.2 | Apache-2.0 OR MIT | https://github.com/indexmap-rs/equivalent |
|
|
||||||
| errno | 0.3.14 | MIT OR Apache-2.0 | https://github.com/lambda-fairy/rust-errno |
|
|
||||||
| fastbloom | 0.17.0 | MIT OR Apache-2.0 | https://github.com/tomtomwombat/fastbloom/ |
|
|
||||||
| fastrand | 2.3.0 | Apache-2.0 OR MIT | https://github.com/smol-rs/fastrand |
|
|
||||||
| fiat-crypto | 0.2.9 | MIT OR Apache-2.0 OR BSD-1-Clause | https://github.com/mit-plv/fiat-crypto |
|
|
||||||
| fiat-crypto | 0.3.0 | MIT OR Apache-2.0 OR BSD-1-Clause | https://github.com/mit-plv/fiat-crypto |
|
|
||||||
| filetime | 0.2.27 | MIT/Apache-2.0 | https://github.com/alexcrichton/filetime |
|
|
||||||
| find-msvc-tools | 0.1.8 | MIT OR Apache-2.0 | https://github.com/rust-lang/cc-rs |
|
|
||||||
| flate2 | 1.1.9 | MIT OR Apache-2.0 | https://github.com/rust-lang/flate2-rs |
|
|
||||||
| flume | 0.11.1 | Apache-2.0/MIT | https://github.com/zesterer/flume |
|
|
||||||
| fnv | 1.0.7 | Apache-2.0 / MIT | https://github.com/servo/rust-fnv |
|
|
||||||
| foldhash | 0.1.5 | Zlib | https://github.com/orlp/foldhash |
|
|
||||||
| foldhash | 0.2.0 | Zlib | https://github.com/orlp/foldhash |
|
|
||||||
| form_urlencoded | 1.2.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url |
|
|
||||||
| futures | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-buffered | 0.2.13 | MIT | https://github.com/conradludgate/futures-buffered |
|
|
||||||
| futures-channel | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-core | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-executor | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-io | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-lite | 2.6.1 | Apache-2.0 OR MIT | https://github.com/smol-rs/futures-lite |
|
|
||||||
| futures-macro | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-sink | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-task | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| futures-util | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
|
||||||
| genawaiter | 0.99.1 | MIT | https://github.com/whatisaphone/genawaiter |
|
|
||||||
| genawaiter-macro | 0.99.1 | MIT/Apache-2.0 | https://github.com/whatisaphone/genawaiter |
|
|
||||||
| genawaiter-proc-macro | 0.99.1 | MIT/Apache-2.0 | https://github.com/whatisaphone/genawaiter |
|
|
||||||
| generator | 0.8.9 | MIT/Apache-2.0 | https://github.com/Xudong-Huang/generator-rs.git |
|
|
||||||
| generic-array | 0.14.7 | MIT | https://github.com/fizyk20/generic-array.git |
|
|
||||||
| getrandom | 0.2.17 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
|
|
||||||
| getrandom | 0.3.4 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
|
|
||||||
| getrandom | 0.4.2 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
|
|
||||||
| ghash | 0.5.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
|
|
||||||
| gloo-timers | 0.3.0 | MIT OR Apache-2.0 | https://github.com/rustwasm/gloo/tree/master/crates/timers |
|
|
||||||
| h2 | 0.3.27 | MIT | https://github.com/hyperium/h2 |
|
|
||||||
| h2 | 0.4.13 | MIT | https://github.com/hyperium/h2 |
|
|
||||||
| half | 2.7.1 | MIT OR Apache-2.0 | https://github.com/VoidStarKat/half-rs |
|
|
||||||
| hash32 | 0.2.1 | MIT OR Apache-2.0 | https://github.com/japaric/hash32 |
|
|
||||||
| hashbrown | 0.12.3 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
|
|
||||||
| hashbrown | 0.15.5 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
|
|
||||||
| hashbrown | 0.16.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
|
|
||||||
| hashbrown | 0.17.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
|
|
||||||
| heapless | 0.7.17 | MIT OR Apache-2.0 | https://github.com/japaric/heapless |
|
|
||||||
| heck | 0.5.0 | MIT OR Apache-2.0 | https://github.com/withoutboats/heck |
|
|
||||||
| hermit-abi | 0.5.2 | MIT OR Apache-2.0 | https://github.com/hermit-os/hermit-rs |
|
|
||||||
| hex | 0.4.3 | MIT OR Apache-2.0 | https://github.com/KokaKiwi/rust-hex |
|
|
||||||
| hex-conservative | 0.1.2 | CC0-1.0 | https://github.com/rust-bitcoin/hex-conservative |
|
|
||||||
| hex-conservative | 0.2.2 | CC0-1.0 | https://github.com/rust-bitcoin/hex-conservative |
|
|
||||||
| hex_lit | 0.1.1 | MITNFA | https://github.com/Kixunil/hex_lit |
|
|
||||||
| hickory-net | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
|
|
||||||
| hickory-proto | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
|
|
||||||
| hickory-resolver | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
|
|
||||||
| hkdf | 0.12.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/KDFs/ |
|
|
||||||
| hmac | 0.12.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/MACs |
|
|
||||||
| http | 0.2.12 | MIT OR Apache-2.0 | https://github.com/hyperium/http |
|
|
||||||
| http | 1.4.0 | MIT OR Apache-2.0 | https://github.com/hyperium/http |
|
|
||||||
| http-body | 0.4.6 | MIT | https://github.com/hyperium/http-body |
|
|
||||||
| http-body | 1.0.1 | MIT | https://github.com/hyperium/http-body |
|
|
||||||
| http-body-util | 0.1.3 | MIT | https://github.com/hyperium/http-body |
|
|
||||||
| httparse | 1.10.1 | MIT OR Apache-2.0 | https://github.com/seanmonstar/httparse |
|
|
||||||
| httpdate | 1.0.3 | MIT OR Apache-2.0 | https://github.com/pyfisch/httpdate |
|
|
||||||
| humantime | 2.3.0 | MIT OR Apache-2.0 | https://github.com/chronotope/humantime |
|
|
||||||
| hybrid-array | 0.4.12 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hybrid-array |
|
|
||||||
| hyper | 0.14.32 | MIT | https://github.com/hyperium/hyper |
|
|
||||||
| hyper | 1.8.1 | MIT | https://github.com/hyperium/hyper |
|
|
||||||
| hyper-rustls | 0.24.2 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/hyper-rustls |
|
|
||||||
| hyper-rustls | 0.27.9 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/hyper-rustls |
|
|
||||||
| hyper-util | 0.1.19 | MIT | https://github.com/hyperium/hyper-util |
|
|
||||||
| hyper-ws-listener | 0.3.0 | MIT | |
|
|
||||||
| iana-time-zone | 0.1.64 | MIT OR Apache-2.0 | https://github.com/strawlab/iana-time-zone |
|
|
||||||
| iana-time-zone-haiku | 0.1.2 | MIT OR Apache-2.0 | https://github.com/strawlab/iana-time-zone |
|
|
||||||
| icu_collections | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| icu_locale_core | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| icu_normalizer | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| icu_normalizer_data | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| icu_properties | 2.1.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| icu_properties_data | 2.1.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| icu_provider | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| id-arena | 2.3.0 | MIT/Apache-2.0 | https://github.com/fitzgen/id-arena |
|
|
||||||
| ident_case | 1.0.1 | MIT/Apache-2.0 | https://github.com/TedDriggs/ident_case |
|
|
||||||
| identity-hash | 0.1.0 | Apache-2.0 OR MIT | https://github.com/offsetting/identity-hash |
|
|
||||||
| idna | 1.1.0 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ |
|
|
||||||
| idna_adapter | 1.2.1 | Apache-2.0 OR MIT | https://github.com/hsivonen/idna_adapter |
|
|
||||||
| if-addrs | 0.15.0 | MIT OR BSD-3-Clause | https://github.com/messense/if-addrs |
|
|
||||||
| igd-next | 0.17.1 | MIT | https://github.com/dariusc93/rust-igd |
|
|
||||||
| image | 0.25.9 | MIT OR Apache-2.0 | https://github.com/image-rs/image |
|
|
||||||
| indexmap | 2.13.0 | Apache-2.0 OR MIT | https://github.com/indexmap-rs/indexmap |
|
|
||||||
| inout | 0.1.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
|
||||||
| inplace-vec-builder | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rklaehn/inplace-vec-builder |
|
|
||||||
| instant | 0.1.13 | BSD-3-Clause | https://github.com/sebcrozet/instant |
|
|
||||||
| ipconfig | 0.3.4 | MIT/Apache-2.0 | https://github.com/liranringel/ipconfig |
|
|
||||||
| ipnet | 2.12.0 | MIT OR Apache-2.0 | https://github.com/krisprice/ipnet |
|
|
||||||
| iri-string | 0.7.12 | MIT OR Apache-2.0 | https://github.com/lo48576/iri-string |
|
|
||||||
| iroh | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
|
||||||
| iroh-base | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
|
||||||
| iroh-blobs | 0.103.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-blobs |
|
|
||||||
| iroh-dns | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
|
||||||
| iroh-io | 0.6.2 | Apache-2.0 OR MIT | https://github.com/n0-computer/iroh |
|
|
||||||
| iroh-metrics | 1.0.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-metrics |
|
|
||||||
| iroh-metrics-derive | 1.0.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-metrics |
|
|
||||||
| iroh-relay | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
|
||||||
| iroh-tickets | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-tickets |
|
|
||||||
| iroh-util | 0.6.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-util |
|
|
||||||
| irpc | 0.17.0 | Apache-2.0/MIT | https://github.com/n0-computer/irpc |
|
|
||||||
| irpc-derive | 0.17.0 | Apache-2.0/MIT | https://github.com/n0-computer/irpc |
|
|
||||||
| is-terminal | 0.4.17 | MIT | https://github.com/sunfishcode/is-terminal |
|
|
||||||
| itoa | 1.0.17 | MIT OR Apache-2.0 | https://github.com/dtolnay/itoa |
|
|
||||||
| jni | 0.21.1 | MIT/Apache-2.0 | https://github.com/jni-rs/jni-rs |
|
|
||||||
| jni | 0.22.4 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-rs |
|
|
||||||
| jni-macros | 0.22.4 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-rs |
|
|
||||||
| jni-sys | 0.3.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
|
|
||||||
| jni-sys | 0.4.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
|
|
||||||
| jni-sys-macros | 0.4.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
|
|
||||||
| js-sys | 0.3.85 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/js-sys |
|
|
||||||
| lazy_static | 1.5.0 | MIT OR Apache-2.0 | https://github.com/rust-lang-nursery/lazy-static.rs |
|
|
||||||
| leb128fmt | 0.1.0 | MIT OR Apache-2.0 | https://github.com/bluk/leb128fmt |
|
|
||||||
| libc | 0.2.180 | MIT OR Apache-2.0 | https://github.com/rust-lang/libc |
|
|
||||||
| libm | 0.2.16 | MIT | https://github.com/rust-lang/compiler-builtins |
|
|
||||||
| libredox | 0.1.14 | MIT | https://gitlab.redox-os.org/redox-os/libredox.git |
|
|
||||||
| libssh2-sys | 0.3.1 | MIT OR Apache-2.0 | https://github.com/alexcrichton/ssh2-rs |
|
|
||||||
| libz-sys | 1.1.29 | MIT OR Apache-2.0 | https://github.com/rust-lang/libz-sys |
|
|
||||||
| linux-raw-sys | 0.11.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/sunfishcode/linux-raw-sys |
|
|
||||||
| litemap | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| lock_api | 0.4.14 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
|
|
||||||
| log | 0.4.29 | MIT OR Apache-2.0 | https://github.com/rust-lang/log |
|
|
||||||
| loom | 0.7.2 | MIT | https://github.com/tokio-rs/loom |
|
|
||||||
| lru | 0.12.5 | MIT | https://github.com/jeromefroe/lru-rs.git |
|
|
||||||
| lru | 0.16.3 | MIT | https://github.com/jeromefroe/lru-rs.git |
|
|
||||||
| lru | 0.18.0 | MIT | https://github.com/jeromefroe/lru-rs.git |
|
|
||||||
| lru | 0.7.8 | MIT | https://github.com/jeromefroe/lru-rs.git |
|
|
||||||
| lru-slab | 0.1.2 | MIT OR Apache-2.0 OR Zlib | https://github.com/Ralith/lru-slab |
|
|
||||||
| mac-addr | 0.3.0 | MIT | https://github.com/shellrow/mac-addr |
|
|
||||||
| mainline | 2.0.1 | MIT | https://github.com/nuhvi/mainline |
|
|
||||||
| matchers | 0.2.0 | MIT | https://github.com/hawkw/matchers |
|
|
||||||
| mdns-sd | 0.18.2 | Apache-2.0 OR MIT | https://github.com/keepsimple1/mdns-sd |
|
|
||||||
| memchr | 2.7.6 | Unlicense OR MIT | https://github.com/BurntSushi/memchr |
|
|
||||||
| mime | 0.3.17 | MIT OR Apache-2.0 | https://github.com/hyperium/mime |
|
|
||||||
| minimal-lexical | 0.2.1 | MIT/Apache-2.0 | https://github.com/Alexhuszagh/minimal-lexical |
|
|
||||||
| miniz_oxide | 0.8.9 | MIT OR Zlib OR Apache-2.0 | https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide |
|
|
||||||
| mio | 1.1.1 | MIT | https://github.com/tokio-rs/mio |
|
|
||||||
| moka | 0.12.15 | (MIT OR Apache-2.0) AND Apache-2.0 | https://github.com/moka-rs/moka |
|
|
||||||
| moxcms | 0.7.11 | BSD-3-Clause OR Apache-2.0 | https://github.com/awxkee/moxcms.git |
|
|
||||||
| n0-error | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-error |
|
|
||||||
| n0-error-macros | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-error |
|
|
||||||
| n0-future | 0.3.2 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-future |
|
|
||||||
| n0-watcher | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-watcher |
|
|
||||||
| ndk-context | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rust-windowing/android-ndk-rs |
|
|
||||||
| negentropy | 0.5.0 | MIT | https://github.com/rust-nostr/negentropy.git |
|
|
||||||
| nested_enum_utils | 0.2.3 | MIT OR Apache-2.0 | https://github.com/n0-computer/nested-enum-utils |
|
|
||||||
| netdev | 0.44.0 | MIT | https://github.com/shellrow/netdev |
|
|
||||||
| netlink-packet-core | 0.8.1 | MIT | https://github.com/rust-netlink/netlink-packet-core |
|
|
||||||
| netlink-packet-route | 0.29.0 | MIT | https://github.com/rust-netlink/netlink-packet-route |
|
|
||||||
| netlink-packet-route | 0.31.0 | MIT | https://github.com/rust-netlink/netlink-packet-route |
|
|
||||||
| netlink-proto | 0.12.0 | MIT | https://github.com/rust-netlink/netlink-proto |
|
|
||||||
| netlink-sys | 0.8.8 | MIT | https://github.com/rust-netlink/netlink-sys |
|
|
||||||
| netwatch | 0.19.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/net-tools |
|
|
||||||
| nom | 7.1.3 | MIT | https://github.com/Geal/nom |
|
|
||||||
| noq | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
|
|
||||||
| noq-proto | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
|
|
||||||
| noq-udp | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
|
|
||||||
| nostr | 0.44.2 | MIT | https://github.com/rust-nostr/nostr.git |
|
|
||||||
| nostr-database | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
|
|
||||||
| nostr-gossip | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
|
|
||||||
| nostr-relay-pool | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
|
|
||||||
| nostr-sdk | 0.44.1 | MIT | https://github.com/rust-nostr/nostr.git |
|
|
||||||
| nu-ansi-term | 0.50.3 | MIT | https://github.com/nushell/nu-ansi-term |
|
|
||||||
| num-bigint | 0.4.6 | MIT OR Apache-2.0 | https://github.com/rust-num/num-bigint |
|
|
||||||
| num-conv | 0.2.2 | MIT OR Apache-2.0 | https://github.com/jhpratt/num-conv |
|
|
||||||
| num-integer | 0.1.46 | MIT OR Apache-2.0 | https://github.com/rust-num/num-integer |
|
|
||||||
| num-traits | 0.2.19 | MIT OR Apache-2.0 | https://github.com/rust-num/num-traits |
|
|
||||||
| num_enum | 0.7.6 | BSD-3-Clause OR MIT OR Apache-2.0 | https://github.com/illicitonion/num_enum |
|
|
||||||
| num_enum_derive | 0.7.6 | BSD-3-Clause OR MIT OR Apache-2.0 | https://github.com/illicitonion/num_enum |
|
|
||||||
| num_threads | 0.1.7 | MIT OR Apache-2.0 | https://github.com/jhpratt/num_threads |
|
|
||||||
| objc2 | 0.6.4 | MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| objc2-core-foundation | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| objc2-core-wlan | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| objc2-encode | 4.1.0 | MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| objc2-foundation | 0.3.2 | MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| objc2-security | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| objc2-security-foundation | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| objc2-system-configuration | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
|
||||||
| oid-registry | 0.8.1 | MIT OR Apache-2.0 | https://github.com/rusticata/oid-registry.git |
|
|
||||||
| once_cell | 1.21.3 | MIT OR Apache-2.0 | https://github.com/matklad/once_cell |
|
|
||||||
| opaque-debug | 0.3.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
|
||||||
| openssl-probe | 0.2.1 | MIT OR Apache-2.0 | https://github.com/rustls/openssl-probe |
|
|
||||||
| openssl-sys | 0.9.117 | MIT | https://github.com/rust-openssl/rust-openssl |
|
|
||||||
| papaya | 0.2.4 | MIT | https://github.com/ibraheemdev/papaya |
|
|
||||||
| parking | 2.2.1 | Apache-2.0 OR MIT | https://github.com/smol-rs/parking |
|
|
||||||
| parking_lot | 0.11.2 | Apache-2.0/MIT | https://github.com/Amanieu/parking_lot |
|
|
||||||
| parking_lot | 0.12.5 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
|
|
||||||
| parking_lot_core | 0.8.6 | Apache-2.0/MIT | https://github.com/Amanieu/parking_lot |
|
|
||||||
| parking_lot_core | 0.9.12 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
|
|
||||||
| password-hash | 0.5.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits/tree/master/password-hash |
|
|
||||||
| paste | 1.0.15 | MIT OR Apache-2.0 | https://github.com/dtolnay/paste |
|
|
||||||
| pbkdf2 | 0.12.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/pbkdf2 |
|
|
||||||
| pem | 3.0.6 | MIT | https://github.com/jcreekmore/pem-rs.git |
|
|
||||||
| pem-rfc7468 | 1.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| percent-encoding | 2.3.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ |
|
|
||||||
| pharos | 0.5.3 | Unlicense | https://github.com/najamelan/pharos |
|
|
||||||
| pin-project | 1.1.13 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project |
|
|
||||||
| pin-project-internal | 1.1.13 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project |
|
|
||||||
| pin-project-lite | 0.2.16 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project-lite |
|
|
||||||
| pin-utils | 0.1.0 | MIT OR Apache-2.0 | https://github.com/rust-lang-nursery/pin-utils |
|
|
||||||
| pkcs8 | 0.10.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/pkcs8 |
|
|
||||||
| pkcs8 | 0.11.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| pkg-config | 0.3.33 | MIT OR Apache-2.0 | https://github.com/rust-lang/pkg-config-rs |
|
|
||||||
| plain | 0.2.3 | MIT/Apache-2.0 | https://github.com/randomites/plain |
|
|
||||||
| plist | 1.9.0 | MIT | https://github.com/ebarnard/rust-plist/ |
|
|
||||||
| poly1305 | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
|
|
||||||
| polyval | 0.6.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
|
|
||||||
| portable-atomic | 1.13.1 | Apache-2.0 OR MIT | https://github.com/taiki-e/portable-atomic |
|
|
||||||
| portmapper | 0.19.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/net-tools |
|
|
||||||
| positioned-io | 0.3.5 | MIT | https://github.com/vasi/positioned-io |
|
|
||||||
| postcard | 1.1.3 | MIT OR Apache-2.0 | https://github.com/jamesmunns/postcard |
|
|
||||||
| postcard-derive | 0.2.2 | MIT OR Apache-2.0 | https://github.com/jamesmunns/postcard |
|
|
||||||
| potential_utf | 0.1.4 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| powerfmt | 0.2.0 | MIT OR Apache-2.0 | https://github.com/jhpratt/powerfmt |
|
|
||||||
| ppv-lite86 | 0.2.21 | MIT OR Apache-2.0 | https://github.com/cryptocorrosion/cryptocorrosion |
|
|
||||||
| prefix-trie | 0.8.4 | MIT OR Apache-2.0 | https://github.com/tiborschneider/prefix-trie |
|
|
||||||
| prettyplease | 0.2.37 | MIT OR Apache-2.0 | https://github.com/dtolnay/prettyplease |
|
|
||||||
| proc-macro-crate | 3.5.0 | MIT OR Apache-2.0 | https://github.com/bkchr/proc-macro-crate |
|
|
||||||
| proc-macro-error | 0.4.12 | MIT OR Apache-2.0 | https://gitlab.com/CreepySkeleton/proc-macro-error |
|
|
||||||
| proc-macro-error-attr | 0.4.12 | MIT OR Apache-2.0 | https://gitlab.com/CreepySkeleton/proc-macro-error |
|
|
||||||
| proc-macro-hack | 0.5.20+deprecated | MIT OR Apache-2.0 | https://github.com/dtolnay/proc-macro-hack |
|
|
||||||
| proc-macro2 | 1.0.106 | MIT OR Apache-2.0 | https://github.com/dtolnay/proc-macro2 |
|
|
||||||
| pxfm | 0.1.28 | BSD-3-Clause OR Apache-2.0 | https://github.com/awxkee/pxfm |
|
|
||||||
| qrcode | 0.14.1 | MIT OR Apache-2.0 | https://github.com/kennytm/qrcode-rust |
|
|
||||||
| quick-xml | 0.39.4 | MIT | https://github.com/tafia/quick-xml |
|
|
||||||
| quote | 1.0.44 | MIT OR Apache-2.0 | https://github.com/dtolnay/quote |
|
|
||||||
| r-efi | 5.3.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | https://github.com/r-efi/r-efi |
|
|
||||||
| r-efi | 6.0.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | https://github.com/r-efi/r-efi |
|
|
||||||
| rand | 0.10.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
|
||||||
| rand | 0.8.5 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
|
||||||
| rand | 0.9.2 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
|
||||||
| rand_chacha | 0.3.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
|
||||||
| rand_chacha | 0.9.0 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
|
||||||
| rand_core | 0.10.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand_core |
|
|
||||||
| rand_core | 0.6.4 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
|
||||||
| rand_core | 0.9.5 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
|
||||||
| rand_pcg | 0.10.2 | MIT OR Apache-2.0 | https://github.com/rust-random/rngs |
|
|
||||||
| range-collections | 0.4.6 | MIT OR Apache-2.0 | https://github.com/rklaehn/range-collections |
|
|
||||||
| rcgen | 0.14.8 | MIT OR Apache-2.0 | https://github.com/rustls/rcgen |
|
|
||||||
| redb | 4.1.0 | MIT OR Apache-2.0 | https://github.com/cberner/redb |
|
|
||||||
| redox_syscall | 0.2.16 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
|
|
||||||
| redox_syscall | 0.5.18 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
|
|
||||||
| redox_syscall | 0.7.3 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
|
|
||||||
| reed-solomon-erasure | 6.0.0 | MIT | https://github.com/darrenldl/reed-solomon-erasure |
|
|
||||||
| ref-cast | 1.0.25 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast |
|
|
||||||
| ref-cast-impl | 1.0.25 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast |
|
|
||||||
| reflink-copy | 0.1.29 | MIT/Apache-2.0 | https://github.com/cargo-bins/reflink-copy |
|
|
||||||
| regex | 1.12.2 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
|
|
||||||
| regex-automata | 0.4.13 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
|
|
||||||
| regex-syntax | 0.8.8 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
|
|
||||||
| reqwest | 0.11.27 | MIT OR Apache-2.0 | https://github.com/seanmonstar/reqwest |
|
|
||||||
| reqwest | 0.13.4 | MIT OR Apache-2.0 | https://github.com/seanmonstar/reqwest |
|
|
||||||
| resolv-conf | 0.7.6 | MIT OR Apache-2.0 | https://github.com/hickory-dns/resolv-conf |
|
|
||||||
| ring | 0.17.14 | Apache-2.0 AND ISC | https://github.com/briansmith/ring |
|
|
||||||
| rustc-hash | 2.1.2 | Apache-2.0 OR MIT | https://github.com/rust-lang/rustc-hash |
|
|
||||||
| rustc_version | 0.4.1 | MIT OR Apache-2.0 | https://github.com/djc/rustc-version-rs |
|
|
||||||
| rusticata-macros | 4.1.0 | MIT/Apache-2.0 | https://github.com/rusticata/rusticata-macros.git |
|
|
||||||
| rustix | 1.1.3 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/rustix |
|
|
||||||
| rustls | 0.21.12 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls |
|
|
||||||
| rustls | 0.23.36 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls |
|
|
||||||
| rustls-native-certs | 0.8.4 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls-native-certs |
|
|
||||||
| rustls-pemfile | 1.0.4 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/pemfile |
|
|
||||||
| rustls-pki-types | 1.14.0 | MIT OR Apache-2.0 | https://github.com/rustls/pki-types |
|
|
||||||
| rustls-platform-verifier | 0.7.0 | MIT OR Apache-2.0 | https://github.com/rustls/rustls-platform-verifier |
|
|
||||||
| rustls-platform-verifier-android | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rustls/rustls-platform-verifier |
|
|
||||||
| rustls-webpki | 0.101.7 | ISC | https://github.com/rustls/webpki |
|
|
||||||
| rustls-webpki | 0.103.9 | ISC | https://github.com/rustls/webpki |
|
|
||||||
| rustversion | 1.0.22 | MIT OR Apache-2.0 | https://github.com/dtolnay/rustversion |
|
|
||||||
| ryu | 1.0.22 | Apache-2.0 OR BSL-1.0 | https://github.com/dtolnay/ryu |
|
|
||||||
| salsa20 | 0.10.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/stream-ciphers |
|
|
||||||
| same-file | 1.0.6 | Unlicense/MIT | https://github.com/BurntSushi/same-file |
|
|
||||||
| schannel | 0.1.29 | MIT | https://github.com/steffengy/schannel-rs |
|
|
||||||
| scoped-tls | 1.0.1 | MIT/Apache-2.0 | https://github.com/alexcrichton/scoped-tls |
|
|
||||||
| scopeguard | 1.2.0 | MIT OR Apache-2.0 | https://github.com/bluss/scopeguard |
|
|
||||||
| scrypt | 0.11.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/scrypt |
|
|
||||||
| sct | 0.7.1 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/sct.rs |
|
|
||||||
| sd-notify | 0.4.5 | MIT OR Apache-2.0 | https://github.com/lnicola/sd-notify |
|
|
||||||
| secp256k1 | 0.29.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-secp256k1/ |
|
|
||||||
| secp256k1-sys | 0.10.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-secp256k1/ |
|
|
||||||
| security-framework | 3.7.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework |
|
|
||||||
| security-framework-sys | 2.17.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework |
|
|
||||||
| seize | 0.5.1 | MIT | https://github.com/ibraheemdev/seize |
|
|
||||||
| self_cell | 1.2.2 | Apache-2.0 OR GPL-2.0-only | https://github.com/Voultapher/self_cell |
|
|
||||||
| semver | 1.0.27 | MIT OR Apache-2.0 | https://github.com/dtolnay/semver |
|
|
||||||
| send_wrapper | 0.6.0 | MIT/Apache-2.0 | https://github.com/thk1/send_wrapper |
|
|
||||||
| serde | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
|
|
||||||
| serde_bencode | 0.2.4 | MIT | https://github.com/toby/serde-bencode |
|
|
||||||
| serde_bytes | 0.11.19 | MIT OR Apache-2.0 | https://github.com/serde-rs/bytes |
|
|
||||||
| serde_core | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
|
|
||||||
| serde_derive | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
|
|
||||||
| serde_json | 1.0.149 | MIT OR Apache-2.0 | https://github.com/serde-rs/json |
|
|
||||||
| serde_spanned | 0.6.9 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| serde_urlencoded | 0.7.1 | MIT/Apache-2.0 | https://github.com/nox/serde_urlencoded |
|
|
||||||
| serde_yaml | 0.9.34+deprecated | MIT OR Apache-2.0 | https://github.com/dtolnay/serde-yaml |
|
|
||||||
| serdect | 0.4.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| serial2 | 0.2.34 | BSD-2-Clause OR Apache-2.0 | https://github.com/de-vri-es/serial2-rs |
|
|
||||||
| serial2-tokio | 0.1.21 | BSD-2-Clause OR Apache-2.0 | https://github.com/de-vri-es/serial2-tokio-rs |
|
|
||||||
| sha-1 | 0.10.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
|
||||||
| sha1 | 0.10.6 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
|
||||||
| sha1_smol | 1.0.1 | BSD-3-Clause | https://github.com/mitsuhiko/sha1-smol |
|
|
||||||
| sha2 | 0.10.9 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
|
||||||
| sha2 | 0.11.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
|
||||||
| sharded-slab | 0.1.7 | MIT | https://github.com/hawkw/sharded-slab |
|
|
||||||
| shlex | 1.3.0 | MIT OR Apache-2.0 | https://github.com/comex/rust-shlex |
|
|
||||||
| signal-hook-registry | 1.4.8 | MIT OR Apache-2.0 | https://github.com/vorner/signal-hook |
|
|
||||||
| signature | 2.2.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/traits/tree/master/signature |
|
|
||||||
| signature | 3.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/traits |
|
|
||||||
| simd-adler32 | 0.3.8 | MIT | https://github.com/mcountryman/simd-adler32 |
|
|
||||||
| simd_cesu8 | 1.1.1 | Apache-2.0 OR MIT | https://github.com/seancroach/simd_cesu8 |
|
|
||||||
| simdutf8 | 0.1.5 | MIT OR Apache-2.0 | https://github.com/rusticstuff/simdutf8 |
|
|
||||||
| simple-dns | 0.11.3 | MIT | https://github.com/balliegojr/simple-dns |
|
|
||||||
| siphasher | 1.0.3 | MIT/Apache-2.0 | https://github.com/jedisct1/rust-siphash |
|
|
||||||
| slab | 0.4.11 | MIT | https://github.com/tokio-rs/slab |
|
|
||||||
| smallvec | 1.15.1 | MIT OR Apache-2.0 | https://github.com/servo/rust-smallvec |
|
|
||||||
| socket-pktinfo | 0.3.2 | MIT | https://github.com/pixsper/socket-pktinfo |
|
|
||||||
| socket2 | 0.5.10 | MIT OR Apache-2.0 | https://github.com/rust-lang/socket2 |
|
|
||||||
| socket2 | 0.6.2 | MIT OR Apache-2.0 | https://github.com/rust-lang/socket2 |
|
|
||||||
| sorted-index-buffer | 0.2.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
|
||||||
| spez | 0.1.2 | BSD-2-Clause | https://github.com/m-ou-se/spez |
|
|
||||||
| spin | 0.10.0 | MIT | https://github.com/mvdnes/spin-rs.git |
|
|
||||||
| spin | 0.9.8 | MIT | https://github.com/mvdnes/spin-rs.git |
|
|
||||||
| spki | 0.7.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/spki |
|
|
||||||
| spki | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
|
||||||
| ssh2 | 0.9.5 | MIT OR Apache-2.0 | https://github.com/alexcrichton/ssh2-rs |
|
|
||||||
| stable_deref_trait | 1.2.1 | MIT OR Apache-2.0 | https://github.com/storyyeller/stable_deref_trait |
|
|
||||||
| strsim | 0.11.1 | MIT | https://github.com/rapidfuzz/strsim-rs |
|
|
||||||
| strum | 0.28.0 | MIT | https://github.com/Peternator7/strum |
|
|
||||||
| strum_macros | 0.28.0 | MIT | https://github.com/Peternator7/strum |
|
|
||||||
| subtle | 2.6.1 | BSD-3-Clause | https://github.com/dalek-cryptography/subtle |
|
|
||||||
| syn | 1.0.109 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn |
|
|
||||||
| syn | 2.0.114 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn |
|
|
||||||
| syn-mid | 0.5.4 | Apache-2.0 OR MIT | https://github.com/taiki-e/syn-mid |
|
|
||||||
| sync_wrapper | 0.1.2 | Apache-2.0 | https://github.com/Actyx/sync_wrapper |
|
|
||||||
| sync_wrapper | 1.0.2 | Apache-2.0 | https://github.com/Actyx/sync_wrapper |
|
|
||||||
| synstructure | 0.13.2 | MIT | https://github.com/mystor/synstructure |
|
|
||||||
| system-configuration | 0.5.1 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
|
||||||
| system-configuration | 0.6.1 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
|
||||||
| system-configuration | 0.7.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
|
||||||
| system-configuration-sys | 0.5.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
|
||||||
| system-configuration-sys | 0.6.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
|
||||||
| tagptr | 0.2.0 | MIT/Apache-2.0 | https://github.com/oliver-giersch/tagptr.git |
|
|
||||||
| tar | 0.4.44 | MIT OR Apache-2.0 | https://github.com/alexcrichton/tar-rs |
|
|
||||||
| tempfile | 3.24.0 | MIT OR Apache-2.0 | https://github.com/Stebalien/tempfile |
|
|
||||||
| termcolor | 1.4.1 | Unlicense OR MIT | https://github.com/BurntSushi/termcolor |
|
|
||||||
| thiserror | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
|
|
||||||
| thiserror | 2.0.18 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
|
|
||||||
| thiserror-impl | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
|
|
||||||
| thiserror-impl | 2.0.18 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
|
|
||||||
| thread_local | 1.1.9 | MIT OR Apache-2.0 | https://github.com/Amanieu/thread_local-rs |
|
|
||||||
| time | 0.3.49 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
|
|
||||||
| time-core | 0.1.9 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
|
|
||||||
| time-macros | 0.2.29 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
|
|
||||||
| tinystr | 0.8.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| tinyvec | 1.10.0 | Zlib OR Apache-2.0 OR MIT | https://github.com/Lokathor/tinyvec |
|
|
||||||
| tinyvec_macros | 0.1.1 | MIT OR Apache-2.0 OR Zlib | https://github.com/Soveu/tinyvec_macros |
|
|
||||||
| tokio | 1.49.0 | MIT | https://github.com/tokio-rs/tokio |
|
|
||||||
| tokio-macros | 2.6.0 | MIT | https://github.com/tokio-rs/tokio |
|
|
||||||
| tokio-rustls | 0.24.1 | MIT/Apache-2.0 | https://github.com/rustls/tokio-rustls |
|
|
||||||
| tokio-rustls | 0.26.4 | MIT OR Apache-2.0 | https://github.com/rustls/tokio-rustls |
|
|
||||||
| tokio-socks | 0.5.2 | MIT | https://github.com/sticnarf/tokio-socks |
|
|
||||||
| tokio-stream | 0.1.18 | MIT | https://github.com/tokio-rs/tokio |
|
|
||||||
| tokio-test | 0.4.5 | MIT | https://github.com/tokio-rs/tokio |
|
|
||||||
| tokio-tungstenite | 0.20.1 | MIT | https://github.com/snapview/tokio-tungstenite |
|
|
||||||
| tokio-tungstenite | 0.26.2 | MIT | https://github.com/snapview/tokio-tungstenite |
|
|
||||||
| tokio-util | 0.7.18 | MIT | https://github.com/tokio-rs/tokio |
|
|
||||||
| tokio-websockets | 0.13.2 | MIT | https://github.com/Gelbpunkt/tokio-websockets/ |
|
|
||||||
| toml | 0.8.23 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| toml_datetime | 0.6.11 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| toml_datetime | 1.1.1+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| toml_edit | 0.22.27 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| toml_edit | 0.25.12+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| toml_parser | 1.1.2+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| toml_write | 0.1.2 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
|
||||||
| totp-rs | 5.7.0 | MIT | https://github.com/constantoine/totp-rs |
|
|
||||||
| tower | 0.5.3 | MIT | https://github.com/tower-rs/tower |
|
|
||||||
| tower-http | 0.6.8 | MIT | https://github.com/tower-rs/tower-http |
|
|
||||||
| tower-layer | 0.3.3 | MIT | https://github.com/tower-rs/tower |
|
|
||||||
| tower-service | 0.3.3 | MIT | https://github.com/tower-rs/tower |
|
|
||||||
| tracing | 0.1.44 | MIT | https://github.com/tokio-rs/tracing |
|
|
||||||
| tracing-attributes | 0.1.31 | MIT | https://github.com/tokio-rs/tracing |
|
|
||||||
| tracing-core | 0.1.36 | MIT | https://github.com/tokio-rs/tracing |
|
|
||||||
| tracing-log | 0.2.0 | MIT | https://github.com/tokio-rs/tracing |
|
|
||||||
| tracing-subscriber | 0.3.22 | MIT | https://github.com/tokio-rs/tracing |
|
|
||||||
| try-lock | 0.2.5 | MIT | https://github.com/seanmonstar/try-lock |
|
|
||||||
| tungstenite | 0.20.1 | MIT OR Apache-2.0 | https://github.com/snapview/tungstenite-rs |
|
|
||||||
| tungstenite | 0.26.2 | MIT OR Apache-2.0 | https://github.com/snapview/tungstenite-rs |
|
|
||||||
| typenum | 1.20.1 | MIT OR Apache-2.0 | https://github.com/paholg/typenum |
|
|
||||||
| unicode-ident | 1.0.22 | (MIT OR Apache-2.0) AND Unicode-3.0 | https://github.com/dtolnay/unicode-ident |
|
|
||||||
| unicode-normalization | 0.1.22 | MIT/Apache-2.0 | https://github.com/unicode-rs/unicode-normalization |
|
|
||||||
| unicode-segmentation | 1.13.3 | MIT OR Apache-2.0 | https://github.com/unicode-rs/unicode-segmentation |
|
|
||||||
| unicode-xid | 0.2.6 | MIT OR Apache-2.0 | https://github.com/unicode-rs/unicode-xid |
|
|
||||||
| universal-hash | 0.5.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
|
||||||
| unsafe-libyaml | 0.2.11 | MIT | https://github.com/dtolnay/unsafe-libyaml |
|
|
||||||
| untrusted | 0.9.0 | ISC | https://github.com/briansmith/untrusted |
|
|
||||||
| url | 2.5.8 | MIT OR Apache-2.0 | https://github.com/servo/rust-url |
|
|
||||||
| urlencoding | 2.1.3 | MIT | https://github.com/kornelski/rust_urlencoding |
|
|
||||||
| utf-8 | 0.7.6 | MIT OR Apache-2.0 | https://github.com/SimonSapin/rust-utf8 |
|
|
||||||
| utf8_iter | 1.0.4 | Apache-2.0 OR MIT | https://github.com/hsivonen/utf8_iter |
|
|
||||||
| uuid | 1.19.0 | Apache-2.0 OR MIT | https://github.com/uuid-rs/uuid |
|
|
||||||
| valuable | 0.1.1 | MIT | https://github.com/tokio-rs/valuable |
|
|
||||||
| vcpkg | 0.2.15 | MIT/Apache-2.0 | https://github.com/mcgoo/vcpkg-rs |
|
|
||||||
| vergen | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
|
|
||||||
| vergen-gitcl | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
|
|
||||||
| vergen-lib | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
|
|
||||||
| version_check | 0.9.5 | MIT/Apache-2.0 | https://github.com/SergioBenitez/version_check |
|
|
||||||
| walkdir | 2.5.0 | Unlicense/MIT | https://github.com/BurntSushi/walkdir |
|
|
||||||
| want | 0.3.1 | MIT | https://github.com/seanmonstar/want |
|
|
||||||
| wasi | 0.11.1+wasi-snapshot-preview1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi |
|
|
||||||
| wasip2 | 1.0.2+wasi-0.2.9 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi-rs |
|
|
||||||
| wasip3 | 0.4.0+wasi-0.3.0-rc-2026-01-06 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi-rs |
|
|
||||||
| wasm-bindgen | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen |
|
|
||||||
| wasm-bindgen-futures | 0.4.58 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/futures |
|
|
||||||
| wasm-bindgen-macro | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro |
|
|
||||||
| wasm-bindgen-macro-support | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro-support |
|
|
||||||
| wasm-bindgen-shared | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/shared |
|
|
||||||
| wasm-encoder | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-encoder |
|
|
||||||
| wasm-metadata | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-metadata |
|
|
||||||
| wasm-streams | 0.4.2 | MIT OR Apache-2.0 | https://github.com/MattiasBuelens/wasm-streams/ |
|
|
||||||
| wasm-streams | 0.5.0 | MIT OR Apache-2.0 | https://github.com/MattiasBuelens/wasm-streams/ |
|
|
||||||
| wasmparser | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasmparser |
|
|
||||||
| web-sys | 0.3.85 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/web-sys |
|
|
||||||
| web-time | 1.1.0 | MIT OR Apache-2.0 | https://github.com/daxpedda/web-time |
|
|
||||||
| webpki-root-certs | 1.0.7 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
|
|
||||||
| webpki-roots | 0.25.4 | MPL-2.0 | https://github.com/rustls/webpki-roots |
|
|
||||||
| webpki-roots | 0.26.11 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
|
|
||||||
| webpki-roots | 1.0.6 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
|
|
||||||
| widestring | 1.2.1 | MIT OR Apache-2.0 | https://github.com/VoidStarKat/widestring-rs |
|
|
||||||
| winapi | 0.3.9 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
|
|
||||||
| winapi-i686-pc-windows-gnu | 0.4.0 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
|
|
||||||
| winapi-util | 0.1.11 | Unlicense OR MIT | https://github.com/BurntSushi/winapi-util |
|
|
||||||
| winapi-x86_64-pc-windows-gnu | 0.4.0 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
|
|
||||||
| windows | 0.62.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-collections | 0.3.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-core | 0.62.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-future | 0.3.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-implement | 0.60.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-interface | 0.59.3 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-link | 0.2.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-numerics | 0.3.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-registry | 0.6.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-result | 0.4.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-strings | 0.5.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-sys | 0.45.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-sys | 0.48.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-sys | 0.52.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-sys | 0.60.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-sys | 0.61.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-targets | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-targets | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-targets | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-targets | 0.53.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows-threading | 0.2.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_gnullvm | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_gnullvm | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_aarch64_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_gnu | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_gnu | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_gnu | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_gnu | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_i686_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnu | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnu | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnu | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnu | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnullvm | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnullvm | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| windows_x86_64_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
|
||||||
| winnow | 0.7.14 | MIT | https://github.com/winnow-rs/winnow |
|
|
||||||
| winnow | 1.0.3 | MIT | https://github.com/winnow-rs/winnow |
|
|
||||||
| winreg | 0.50.0 | MIT | https://github.com/gentoo90/winreg-rs |
|
|
||||||
| wit-bindgen | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
|
|
||||||
| wit-bindgen-core | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
|
|
||||||
| wit-bindgen-rust | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
|
|
||||||
| wit-bindgen-rust-macro | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
|
|
||||||
| wit-component | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wit-component |
|
|
||||||
| wit-parser | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wit-parser |
|
|
||||||
| wmi | 0.18.4 | MIT OR Apache-2.0 | https://github.com/ohadravid/wmi-rs |
|
|
||||||
| writeable | 0.6.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| ws_stream_wasm | 0.7.5 | Unlicense | https://github.com/najamelan/ws_stream_wasm |
|
|
||||||
| x509-parser | 0.18.1 | MIT OR Apache-2.0 | https://github.com/rusticata/x509-parser.git |
|
|
||||||
| xattr | 1.6.1 | MIT OR Apache-2.0 | https://github.com/Stebalien/xattr |
|
|
||||||
| xml-rs | 0.8.28 | MIT | https://github.com/kornelski/xml-rs |
|
|
||||||
| xmltree | 0.10.3 | MIT | https://github.com/eminence/xmltree-rs |
|
|
||||||
| yasna | 0.6.0 | MIT OR Apache-2.0 | https://github.com/qnighy/yasna.rs |
|
|
||||||
| yoke | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| yoke-derive | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| zbase32 | 0.1.2 | LGPL-3.0+ | https://gitlab.com/pgerber/zbase32-rust |
|
|
||||||
| zerocopy | 0.8.33 | BSD-2-Clause OR Apache-2.0 OR MIT | https://github.com/google/zerocopy |
|
|
||||||
| zerocopy-derive | 0.8.33 | BSD-2-Clause OR Apache-2.0 OR MIT | https://github.com/google/zerocopy |
|
|
||||||
| zerofrom | 0.1.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| zerofrom-derive | 0.1.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| zeroize | 1.9.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
|
|
||||||
| zeroize_derive | 1.5.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
|
|
||||||
| zerotrie | 0.2.3 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| zerovec | 0.11.5 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| zerovec-derive | 0.11.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
|
||||||
| zmij | 1.0.16 | MIT | https://github.com/dtolnay/zmij |
|
|
||||||
@ -465,7 +465,6 @@ impl RpcHandler {
|
|||||||
signing_key.as_ref().map(|i| i.signing_key()),
|
signing_key.as_ref().map(|i| i.signing_key()),
|
||||||
Some(&peer.pubkey),
|
Some(&peer.pubkey),
|
||||||
data.server_info.name.as_deref(),
|
data.server_info.name.as_deref(),
|
||||||
Some(&self.config.data_dir),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@ -279,7 +279,6 @@ impl RpcHandler {
|
|||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.header("X-Federation-DID", local_did)
|
.header("X-Federation-DID", local_did)
|
||||||
.timeout(std::time::Duration::from_secs(120))
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
.fips_timeout(std::time::Duration::from_secs(8))
|
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
.context("Failed to connect to peer")?;
|
.context("Failed to connect to peer")?;
|
||||||
@ -365,11 +364,6 @@ impl RpcHandler {
|
|||||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
// The Cloud page's hottest call: without a fast-fail cap a
|
|
||||||
// cold FIPS path burned ~16.6s before Tor even started,
|
|
||||||
// against the UI's 30s deadline — users saw errors, not
|
|
||||||
// fallback.
|
|
||||||
.fips_timeout(std::time::Duration::from_secs(6))
|
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
.context("Failed to connect to peer")?;
|
.context("Failed to connect to peer")?;
|
||||||
@ -1143,7 +1137,6 @@ impl RpcHandler {
|
|||||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
.fips_timeout(std::time::Duration::from_secs(6))
|
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
.context("Failed to connect to peer for preview")?;
|
.context("Failed to connect to peer for preview")?;
|
||||||
|
|||||||
@ -392,7 +392,6 @@ impl RpcHandler {
|
|||||||
"mesh.status" => self.handle_mesh_status().await,
|
"mesh.status" => self.handle_mesh_status().await,
|
||||||
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
|
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
|
||||||
"mesh.peers" => self.handle_mesh_peers().await,
|
"mesh.peers" => self.handle_mesh_peers().await,
|
||||||
"mesh.refresh" => self.handle_mesh_refresh().await,
|
|
||||||
"mesh.messages" => self.handle_mesh_messages(params).await,
|
"mesh.messages" => self.handle_mesh_messages(params).await,
|
||||||
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
|
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
|
||||||
"mesh.send" => self.handle_mesh_send(params).await,
|
"mesh.send" => self.handle_mesh_send(params).await,
|
||||||
|
|||||||
@ -865,9 +865,7 @@ impl RpcHandler {
|
|||||||
"/rpc/v1",
|
"/rpc/v1",
|
||||||
)
|
)
|
||||||
.service(crate::settings::transport::PeerService::Peers)
|
.service(crate::settings::transport::PeerService::Peers)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30));
|
||||||
.fips_timeout(std::time::Duration::from_secs(6))
|
|
||||||
.record_transport(&self.config.data_dir);
|
|
||||||
|
|
||||||
match req.send_json(&body).await {
|
match req.send_json(&body).await {
|
||||||
Ok((resp, transport)) if resp.status().is_success() => {
|
Ok((resp, transport)) if resp.status().is_success() => {
|
||||||
|
|||||||
@ -13,14 +13,7 @@ use anyhow::Result;
|
|||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
|
||||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||||
let mut v = serde_json::to_value(status)?;
|
Ok(serde_json::to_value(status)?)
|
||||||
// Dial outcome counters (process-lifetime): how often peer dials
|
|
||||||
// used FIPS vs fell back to Tor, broken down by reason. This is
|
|
||||||
// the observability that makes "FIPS uptime" measurable.
|
|
||||||
if let Some(obj) = v.as_object_mut() {
|
|
||||||
obj.insert("dial_stats".to_string(), fips::telemetry::snapshot());
|
|
||||||
}
|
|
||||||
Ok(v)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything the companion app needs to join this node's mesh, embedded
|
/// Everything the companion app needs to join this node's mesh, embedded
|
||||||
@ -90,8 +83,7 @@ impl RpcHandler {
|
|||||||
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
|
||||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||||
fips::config::install(&identity_dir).await?;
|
fips::config::install(&identity_dir).await?;
|
||||||
let unit = fips::service::activation_unit().await;
|
fips::service::activate(fips::SERVICE_UNIT).await?;
|
||||||
fips::service::activate(unit).await?;
|
|
||||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||||
Ok(serde_json::to_value(status)?)
|
Ok(serde_json::to_value(status)?)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
use super::super::RpcHandler;
|
use super::super::RpcHandler;
|
||||||
use crate::mesh;
|
use crate::mesh;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::sync::Arc;
|
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
@ -132,14 +131,7 @@ impl RpcHandler {
|
|||||||
config.broadcast_identity = broadcast;
|
config.broadcast_identity = broadcast;
|
||||||
}
|
}
|
||||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||||
// Empty clears the custom mesh name (falls back to the server
|
config.advert_name = Some(name.to_string());
|
||||||
// name) — without this, a name could be set but never unset.
|
|
||||||
let trimmed = name.trim();
|
|
||||||
config.advert_name = if trimmed.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(trimmed.to_string())
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if let Some(announce) = params
|
if let Some(announce) = params
|
||||||
.get("announce_block_headers")
|
.get("announce_block_headers")
|
||||||
@ -210,24 +202,10 @@ impl RpcHandler {
|
|||||||
|
|
||||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||||
|
|
||||||
// Apply to the running service in the background: configure() may
|
// If we have a running service, update its config
|
||||||
// stop+start the listener (config changes now restart the session so
|
let mut service = self.mesh_service.write().await;
|
||||||
// they actually take effect), and that can take seconds when the old
|
if let Some(svc) = service.as_mut() {
|
||||||
// session is mid-probe. Holding the service write-lock for that long
|
svc.configure(config.clone()).await?;
|
||||||
// inside this handler stalled every concurrent mesh.status/mesh.peers
|
|
||||||
// poll behind it — the UI froze and nginx surfaced 502s. The config is
|
|
||||||
// already persisted above; the UI observes progress via mesh.status.
|
|
||||||
{
|
|
||||||
let service_arc = Arc::clone(&self.mesh_service);
|
|
||||||
let config_for_apply = config.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut service = service_arc.write().await;
|
|
||||||
if let Some(svc) = service.as_mut() {
|
|
||||||
if let Err(e) = svc.configure(config_for_apply).await {
|
|
||||||
tracing::error!("Applying mesh config to running service failed: {e:#}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Mesh config updated");
|
info!("Mesh config updated");
|
||||||
|
|||||||
@ -110,28 +110,6 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::to_value(probe)?)
|
Ok(serde_json::to_value(probe)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// mesh.refresh — Actively refresh discovery state: re-query the radio's
|
|
||||||
/// contact table and re-announce ourselves so quiet-but-alive neighbours
|
|
||||||
/// answer. This is what the UI's Refresh button calls — before it existed
|
|
||||||
/// the button only re-read server caches and never touched the radio.
|
|
||||||
pub(in crate::api::rpc) async fn handle_mesh_refresh(&self) -> Result<serde_json::Value> {
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let Some(svc) = service.as_ref() else {
|
|
||||||
return Ok(serde_json::json!({ "refreshed": false, "device_connected": false }));
|
|
||||||
};
|
|
||||||
let status = svc.status().await;
|
|
||||||
if status.device_connected {
|
|
||||||
let state = svc.shared_state();
|
|
||||||
let _ = state
|
|
||||||
.send_cmd(crate::mesh::listener::MeshCommand::RefreshContacts)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"refreshed": status.device_connected,
|
|
||||||
"device_connected": status.device_connected,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.peers — List discovered mesh peers.
|
/// mesh.peers — List discovered mesh peers.
|
||||||
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
|
|||||||
@ -575,17 +575,14 @@ impl RpcHandler {
|
|||||||
let nodes = crate::federation::load_nodes(&self.config.data_dir)
|
let nodes = crate::federation::load_nodes(&self.config.data_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let fed_node = nodes.iter().find(|n| {
|
let has_tor = peer_pubkey_hex
|
||||||
peer_pubkey_hex.as_deref() == Some(n.pubkey.as_str())
|
.as_ref()
|
||||||
|| peer_did.as_deref() == Some(n.did.as_str())
|
.map(|pk| nodes.iter().any(|n| &n.pubkey == pk))
|
||||||
});
|
.unwrap_or(false)
|
||||||
let has_tor = fed_node.is_some();
|
|| peer_did
|
||||||
// Distinct FIPS capability so the frontend can offer FIPS as its own
|
.as_ref()
|
||||||
// pill (not just a generic "Tor") — the dial layer still picks
|
.map(|d| nodes.iter().any(|n| &n.did == d))
|
||||||
// FIPS-first with Tor fallback on the actual send; these are honest
|
.unwrap_or(false);
|
||||||
// capability labels, with `last_transport` saying what worked last.
|
|
||||||
let has_fips = fed_node.is_some_and(|n| n.fips_npub.is_some());
|
|
||||||
let last_transport = fed_node.and_then(|n| n.last_transport.clone());
|
|
||||||
|
|
||||||
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
|
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
|
||||||
|
|
||||||
@ -621,8 +618,6 @@ impl RpcHandler {
|
|||||||
"tier": tier,
|
"tier": tier,
|
||||||
"est_seconds": est_seconds,
|
"est_seconds": est_seconds,
|
||||||
"has_tor": has_tor,
|
"has_tor": has_tor,
|
||||||
"has_fips": has_fips,
|
|
||||||
"last_transport": last_transport,
|
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"size": size,
|
"size": size,
|
||||||
"mesh_auto_max": MESH_AUTO_MAX,
|
"mesh_auto_max": MESH_AUTO_MAX,
|
||||||
@ -825,8 +820,6 @@ impl RpcHandler {
|
|||||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), &onion_bare, &path)
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), &onion_bare, &path)
|
||||||
.service(crate::settings::transport::PeerService::MeshFileSharing)
|
.service(crate::settings::transport::PeerService::MeshFileSharing)
|
||||||
.timeout(std::time::Duration::from_secs(120))
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
.fips_timeout(std::time::Duration::from_secs(8))
|
|
||||||
.record_transport(&self.config.data_dir)
|
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;
|
||||||
|
|||||||
@ -137,7 +137,6 @@ impl RpcHandler {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(&self.config.data_dir),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@ -226,7 +225,6 @@ impl RpcHandler {
|
|||||||
signing_key.as_ref().map(|i| i.signing_key()),
|
signing_key.as_ref().map(|i| i.signing_key()),
|
||||||
Some(&req.from_pubkey),
|
Some(&req.from_pubkey),
|
||||||
data.server_info.name.as_deref(),
|
data.server_info.name.as_deref(),
|
||||||
Some(&self.config.data_dir),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@ -133,7 +133,6 @@ impl RpcHandler {
|
|||||||
Some(node_id.signing_key()),
|
Some(node_id.signing_key()),
|
||||||
recipient_pubkey.as_deref(),
|
recipient_pubkey.as_deref(),
|
||||||
node_name.as_deref(),
|
node_name.as_deref(),
|
||||||
Some(&self.config.data_dir),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
||||||
|
|||||||
@ -56,12 +56,12 @@ pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort: install fips.yaml + start the available FIPS systemd unit after
|
/// Best-effort: install fips.yaml + start archipelago-fips.service after the
|
||||||
/// seed onboarding has written the fips_key to disk. Runs in a detached task so
|
/// seed onboarding has written the fips_key to disk. Runs in a detached task
|
||||||
/// the user-facing RPC returns immediately — the systemctl calls can take a few
|
/// so the user-facing RPC returns immediately — the systemctl calls can take
|
||||||
/// seconds the first time on slow hardware. Any failure is logged but does not
|
/// a few seconds the first time on slow hardware. Any failure is logged but
|
||||||
/// break onboarding; the user can still hit fips.install manually from the
|
/// does not break onboarding; the user can still hit fips.install manually
|
||||||
/// dashboard as an escape hatch.
|
/// from the dashboard as an escape hatch.
|
||||||
fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let identity_dir = data_dir.join("identity");
|
let identity_dir = data_dir.join("identity");
|
||||||
@ -78,12 +78,11 @@ fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
|||||||
tracing::warn!("post-onboarding fips config install failed: {}", e);
|
tracing::warn!("post-onboarding fips config install failed: {}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let unit = crate::fips::service::activation_unit().await;
|
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
|
||||||
if let Err(e) = crate::fips::service::activate(unit).await {
|
tracing::warn!("post-onboarding archipelago-fips activate failed: {}", e);
|
||||||
tracing::warn!("post-onboarding FIPS activate failed via {}: {}", unit, e);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tracing::info!("FIPS auto-activated post-onboarding via {}", unit);
|
tracing::info!("archipelago-fips auto-activated post-onboarding");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,8 +138,8 @@ impl RpcHandler {
|
|||||||
// Initialize identity index at 0.
|
// Initialize identity index at 0.
|
||||||
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
||||||
|
|
||||||
// fips_key is now on disk — auto-activate FIPS so the user doesn't
|
// fips_key is now on disk — auto-activate archipelago-fips so the
|
||||||
// have to hit a manual Start button. Detached task;
|
// user doesn't have to hit an "Activate" button. Detached task;
|
||||||
// the onboarding RPC returns immediately.
|
// the onboarding RPC returns immediately.
|
||||||
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
||||||
|
|
||||||
|
|||||||
@ -61,28 +61,6 @@ impl RpcHandler {
|
|||||||
|
|
||||||
info!("Server name updated to: {}", name);
|
info!("Server name updated to: {}", name);
|
||||||
|
|
||||||
// Propagate to the mesh: the listener advertises the server name (when
|
|
||||||
// no explicit mesh advert_name overrides it), but it was only read at
|
|
||||||
// process startup — a rename never reached the radio/RNS until the
|
|
||||||
// next full restart. Push it into the service and bounce the listener
|
|
||||||
// in the background (the restart re-probes the radio, which can take
|
|
||||||
// seconds — don't block the rename response on it).
|
|
||||||
{
|
|
||||||
let mesh_arc = self.mesh_service_arc();
|
|
||||||
let name_for_mesh = name.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut guard = mesh_arc.write().await;
|
|
||||||
if let Some(svc) = guard.as_mut() {
|
|
||||||
svc.set_server_name(Some(name_for_mesh));
|
|
||||||
if svc.config().advert_name.is_none() {
|
|
||||||
if let Err(e) = svc.restart_listener_if_running().await {
|
|
||||||
warn!("Mesh listener restart after rename failed: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push the new name to federation peers in background
|
// Push the new name to federation peers in background
|
||||||
let data_dir = self.config.data_dir.clone();
|
let data_dir = self.config.data_dir.clone();
|
||||||
let state_manager = self.state_manager.clone();
|
let state_manager = self.state_manager.clone();
|
||||||
|
|||||||
@ -498,9 +498,7 @@ pub(super) async fn notify_federation_peers_address_change(
|
|||||||
"/rpc/v1",
|
"/rpc/v1",
|
||||||
)
|
)
|
||||||
.service(crate::settings::transport::PeerService::Peers)
|
.service(crate::settings::transport::PeerService::Peers)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30));
|
||||||
.fips_timeout(std::time::Duration::from_secs(6))
|
|
||||||
.record_transport(data_dir);
|
|
||||||
match req.send_json(&payload).await {
|
match req.send_json(&payload).await {
|
||||||
Ok((_, transport)) => {
|
Ok((_, transport)) => {
|
||||||
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
|
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
|
||||||
|
|||||||
@ -22,10 +22,8 @@
|
|||||||
//! single declarative call.
|
//! single declarative call.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{LazyLock, Mutex};
|
use std::time::Duration;
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@ -37,15 +35,6 @@ const COMPANION_REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
|||||||
const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
|
const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
||||||
const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300);
|
const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
/// After a failed repair (image build/pull included), leave the companion
|
|
||||||
/// alone for this long. Without it, a node under IO pressure retried a 900s
|
|
||||||
/// image build every 30s reconcile tick — each build pegging the disk that
|
|
||||||
/// made the probes fail in the first place (live-diagnosed on zaza-optiplex
|
|
||||||
/// 2026-07-28: load 50, podman scans starved, apps page stuck).
|
|
||||||
const REPAIR_COOLDOWN: Duration = Duration::from_secs(600);
|
|
||||||
|
|
||||||
static REPAIR_FAILED_AT: LazyLock<Mutex<HashMap<&'static str, Instant>>> =
|
|
||||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
||||||
|
|
||||||
/// Static description of one companion. The full list per backend
|
/// Static description of one companion. The full list per backend
|
||||||
/// app_id lives in `companions_for`.
|
/// app_id lives in `companions_for`.
|
||||||
@ -475,28 +464,12 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
|
|||||||
match needs_repair(spec).await {
|
match needs_repair(spec).await {
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
if let Some(failed_at) =
|
|
||||||
REPAIR_FAILED_AT.lock().unwrap().get(spec.name).copied()
|
|
||||||
{
|
|
||||||
if failed_at.elapsed() < REPAIR_COOLDOWN {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info!(
|
info!(
|
||||||
companion = spec.name,
|
companion = spec.name,
|
||||||
"reconcile: companion not active, repairing"
|
"reconcile: companion not active, repairing"
|
||||||
);
|
);
|
||||||
match install_one(spec).await {
|
if let Err(e) = install_one(spec).await {
|
||||||
Ok(()) => {
|
failures.push((spec.name.to_string(), e));
|
||||||
REPAIR_FAILED_AT.lock().unwrap().remove(spec.name);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
REPAIR_FAILED_AT
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.insert(spec.name, Instant::now());
|
|
||||||
failures.push((spec.name.to_string(), e));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@ -511,58 +484,19 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
|
|||||||
|
|
||||||
/// Does this companion need install_one to be re-run? Returns true if
|
/// Does this companion need install_one to be re-run? Returns true if
|
||||||
/// the unit file is missing, stale, or the service is not active.
|
/// the unit file is missing, stale, or the service is not active.
|
||||||
///
|
|
||||||
/// This probe runs every reconcile tick for every companion, so it must be
|
|
||||||
/// PASSIVE: no image builds, no pulls. It used to call ensure_image_present
|
|
||||||
/// to render the expected unit — under IO pressure the image-existence check
|
|
||||||
/// inside timed out, read as "image missing", and a 900s `podman build` ran
|
|
||||||
/// inside the probe even though the companion was up (the .198 load spiral).
|
|
||||||
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
||||||
let dir = quadlet::unit_dir().await?;
|
let dir = quadlet::unit_dir().await?;
|
||||||
let unit_path = dir.join(format!("{}.container", spec.name));
|
let unit_path = dir.join(format!("{}.container", spec.name));
|
||||||
if !fs::try_exists(&unit_path).await.unwrap_or(false) {
|
if !fs::try_exists(&unit_path).await.unwrap_or(false) {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
let svc = format!("{}.service", spec.name);
|
let expected_image = ensure_image_present(spec).await?;
|
||||||
// A hung `systemctl is-active` under IO pressure must not read as
|
let expected_unit = build_unit(spec, &expected_image);
|
||||||
// "companion dead" — that's a repair (and possibly an image build) fired
|
if expected_unit.render() != fs::read_to_string(&unit_path).await.unwrap_or_default() {
|
||||||
// off exactly when the node can least afford one.
|
|
||||||
match tokio::time::timeout(Duration::from_secs(10), quadlet::is_active(&svc)).await {
|
|
||||||
Ok(active) => {
|
|
||||||
if !active {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
warn!(
|
|
||||||
companion = spec.name,
|
|
||||||
"is-active probe timed out; assuming active"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Service is running. Flag it stale only on definitive, cheap signals:
|
|
||||||
// the on-disk unit matching none of the image refs install_one could
|
|
||||||
// have written, or a local build context newer than the built image.
|
|
||||||
let on_disk = fs::read_to_string(&unit_path).await.unwrap_or_default();
|
|
||||||
let local_image = format!("localhost/{}:latest", spec.image_base);
|
|
||||||
let local_image_compat = format!("localhost/{}:local", spec.image_base);
|
|
||||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
|
||||||
let matches_known_shape = [&local_image, &local_image_compat, ®istry_image]
|
|
||||||
.iter()
|
|
||||||
.any(|img| build_unit(spec, img).render() == on_disk);
|
|
||||||
if !matches_known_shape {
|
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
|
let svc = format!("{}.service", spec.name);
|
||||||
for dir in spec.build_dir_candidates {
|
Ok(!quadlet::is_active(&svc).await)
|
||||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
|
||||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
|
||||||
// Conservative on any timeout/error inside: reuse the cache.
|
|
||||||
return Ok(context_is_newer_than_image(dir, &local_image).await);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -232,32 +232,26 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
|
|||||||
/// leaving `anchor_connected=false` and every peer dial falling back to
|
/// leaving `anchor_connected=false` and every peer dial falling back to
|
||||||
/// a slow Tor timeout.
|
/// a slow Tor timeout.
|
||||||
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||||
// Concurrent, each connect hard-capped: the old serial loop waited
|
let mut results = Vec::with_capacity(anchors.len());
|
||||||
// unbounded on every `sudo fipsctl connect`, so one hung subprocess
|
for anchor in anchors {
|
||||||
// stalled the whole apply — and the periodic anchor tick behind it,
|
let out = Command::new("sudo")
|
||||||
// which is exactly when a wedged daemon most needs the re-apply.
|
.args([
|
||||||
let futs = anchors.iter().cloned().map(|anchor| async move {
|
"-n",
|
||||||
let out = tokio::time::timeout(
|
"fipsctl",
|
||||||
std::time::Duration::from_secs(15),
|
"connect",
|
||||||
Command::new("sudo")
|
&anchor.npub,
|
||||||
.args([
|
&anchor.address,
|
||||||
"-n",
|
&anchor.transport,
|
||||||
"fipsctl",
|
])
|
||||||
"connect",
|
.output()
|
||||||
&anchor.npub,
|
.await;
|
||||||
&anchor.address,
|
|
||||||
&anchor.transport,
|
|
||||||
])
|
|
||||||
.output(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let result = match out {
|
let result = match out {
|
||||||
Ok(Ok(o)) if o.status.success() => ApplyResult {
|
Ok(o) if o.status.success() => ApplyResult {
|
||||||
npub: anchor.npub.clone(),
|
npub: anchor.npub.clone(),
|
||||||
ok: true,
|
ok: true,
|
||||||
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||||
},
|
},
|
||||||
Ok(Ok(o)) => ApplyResult {
|
Ok(o) => ApplyResult {
|
||||||
npub: anchor.npub.clone(),
|
npub: anchor.npub.clone(),
|
||||||
ok: false,
|
ok: false,
|
||||||
message: format!(
|
message: format!(
|
||||||
@ -266,16 +260,11 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
|||||||
String::from_utf8_lossy(&o.stderr).trim()
|
String::from_utf8_lossy(&o.stderr).trim()
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
Ok(Err(e)) => ApplyResult {
|
Err(e) => ApplyResult {
|
||||||
npub: anchor.npub.clone(),
|
npub: anchor.npub.clone(),
|
||||||
ok: false,
|
ok: false,
|
||||||
message: format!("sudo fipsctl launch failed: {}", e),
|
message: format!("sudo fipsctl launch failed: {}", e),
|
||||||
},
|
},
|
||||||
Err(_) => ApplyResult {
|
|
||||||
npub: anchor.npub.clone(),
|
|
||||||
ok: false,
|
|
||||||
message: "sudo fipsctl connect timed out after 15s".to_string(),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
if result.ok {
|
if result.ok {
|
||||||
tracing::debug!(npub = %result.npub, "Seed anchor applied");
|
tracing::debug!(npub = %result.npub, "Seed anchor applied");
|
||||||
@ -286,9 +275,9 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
|||||||
"Seed anchor apply failed (non-fatal)"
|
"Seed anchor apply failed (non-fatal)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
result
|
results.push(result);
|
||||||
});
|
}
|
||||||
futures_util::future::join_all(futs).await
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Outcome of a single `fipsctl connect` call.
|
/// Outcome of a single `fipsctl connect` call.
|
||||||
@ -301,12 +290,12 @@ pub struct ApplyResult {
|
|||||||
|
|
||||||
/// FIPS UDP transport port (matches `transports.udp.bind_addr` in the generated
|
/// FIPS UDP transport port (matches `transports.udp.bind_addr` in the generated
|
||||||
/// `fips.yaml`). Direct peer links dial this, NOT the HTTP/LAN messaging port.
|
/// `fips.yaml`). Direct peer links dial this, NOT the HTTP/LAN messaging port.
|
||||||
const FIPS_UDP_PORT: u16 = crate::fips::PUBLISHED_UDP_PORT;
|
const FIPS_UDP_PORT: u16 = 8668;
|
||||||
|
|
||||||
/// Build transient seed-anchor entries that dial LAN-discovered federation peers
|
/// Build transient seed-anchor entries that dial LAN-discovered federation peers
|
||||||
/// directly over their FIPS UDP transport. For each peer the registry knows both
|
/// directly over their FIPS UDP transport. For each peer the registry knows both
|
||||||
/// a LAN socket address AND a FIPS npub for, point a `udp` anchor at
|
/// a LAN socket address AND a FIPS npub for, point a `udp` anchor at
|
||||||
/// `<lan-ip>:<FIPS_UDP_PORT>`. This lets co-located federation nodes form a DIRECT FIPS link
|
/// `<lan-ip>:8668`. This lets co-located federation nodes form a DIRECT FIPS link
|
||||||
/// instead of depending on the global anchor's spanning tree to route between
|
/// instead of depending on the global anchor's spanning tree to route between
|
||||||
/// them (the cause of every dial falling back to Tor when the anchor link flaps).
|
/// them (the cause of every dial falling back to Tor when the anchor link flaps).
|
||||||
///
|
///
|
||||||
@ -459,39 +448,4 @@ mod tests {
|
|||||||
assert_eq!(a.transport, "udp");
|
assert_eq!(a.transport, "udp");
|
||||||
assert_eq!(a.label, "");
|
assert_eq!(a.label, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lan_fips_anchor_port_matches_daemon_bind() {
|
|
||||||
// Drift guard: direct LAN anchors must dial the UDP port the
|
|
||||||
// generated fips.yaml actually binds. These were out of sync for
|
|
||||||
// months (anchors dialed 8668, the daemon bound 2121), making the
|
|
||||||
// whole direct-peering feature dial a dead port.
|
|
||||||
let yaml = crate::fips::config::render_config_yaml();
|
|
||||||
assert!(
|
|
||||||
yaml.contains(&format!("0.0.0.0:{FIPS_UDP_PORT}")),
|
|
||||||
"lan_fips_anchors dials :{FIPS_UDP_PORT} but the daemon config binds elsewhere"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lan_fips_anchors_builds_direct_entry() {
|
|
||||||
let peer = crate::transport::PeerRecord {
|
|
||||||
did: "did:key:zpeer".to_string(),
|
|
||||||
lan_address: Some("192.168.63.198:5678".to_string()),
|
|
||||||
fips_npub: Some("npub1peer".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let out = lan_fips_anchors(&[peer]);
|
|
||||||
assert_eq!(out.len(), 1);
|
|
||||||
assert_eq!(out[0].address, format!("192.168.63.198:{FIPS_UDP_PORT}"));
|
|
||||||
assert_eq!(out[0].transport, "udp");
|
|
||||||
|
|
||||||
// Peers missing either the LAN address or the npub produce nothing.
|
|
||||||
let no_npub = crate::transport::PeerRecord {
|
|
||||||
did: "did:key:zother".to_string(),
|
|
||||||
lan_address: Some("192.168.63.199:5678".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(lan_fips_anchors(&[no_npub]).is_empty());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -240,21 +240,11 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
|
|||||||
// framework-pt). Ship the allowance as a fips.d drop-in on every
|
// framework-pt). Ship the allowance as a fips.d drop-in on every
|
||||||
// install/upgrade so no node ever regresses to a UI-less mesh.
|
// install/upgrade so no node ever regresses to a UI-less mesh.
|
||||||
sudo_install_dir("/etc/fips/fips.d").await?;
|
sudo_install_dir("/etc/fips/fips.d").await?;
|
||||||
// PEER_PORT (5679) carries ALL federation sync, cloud browse/download,
|
let dropin = "# Written by archipelago on every daemon config install.\n\
|
||||||
// mesh envelopes, DWN and invoices. It was missing from this allowlist
|
# Allows the web UI + peer API through the fips0\n\
|
||||||
// while the comment claimed "web UI + peer API" — so every hardened
|
# default-deny inbound baseline (fips.nft).\n\
|
||||||
// node silently dropped peers' FIPS dials at the firewall and the whole
|
tcp dport 80 accept\n\
|
||||||
// fleet fell back to Tor (root-caused live 2026-07-27: 28k drops on
|
tcp dport 8443 accept\n";
|
||||||
// .198's counter; :5679 answered in 0.35s once the rule was inserted).
|
|
||||||
let dropin = format!(
|
|
||||||
"# Written by archipelago on every daemon config install.\n\
|
|
||||||
# Allows the web UI + peer API through the fips0\n\
|
|
||||||
# default-deny inbound baseline (fips.nft).\n\
|
|
||||||
tcp dport 80 accept\n\
|
|
||||||
tcp dport 8443 accept\n\
|
|
||||||
tcp dport {peer_port} accept\n",
|
|
||||||
peer_port = crate::fips::dial::PEER_PORT
|
|
||||||
);
|
|
||||||
let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
|
let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
|
||||||
tokio::fs::write(&nft_stage, dropin)
|
tokio::fs::write(&nft_stage, dropin)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -24,7 +24,6 @@
|
|||||||
//! ```
|
//! ```
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use super::telemetry::{self, FallbackReason};
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::net::{IpAddr, Ipv6Addr};
|
use std::net::{IpAddr, Ipv6Addr};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@ -151,12 +150,6 @@ pub async fn warm_path(npub: &str) {
|
|||||||
if !is_service_active().await {
|
if !is_service_active().await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
warm_path_unchecked(npub).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`warm_path`] without the service-active check — for callers (the warm
|
|
||||||
/// tick) that already verified the daemon once for the whole batch.
|
|
||||||
pub async fn warm_path_unchecked(npub: &str) {
|
|
||||||
let Ok(base) = peer_base_url(npub).await else {
|
let Ok(base) = peer_base_url(npub).await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@ -283,39 +276,21 @@ pub fn as_ip_addr(v6: Ipv6Addr) -> IpAddr {
|
|||||||
|
|
||||||
// ── High-level peer request helpers ────────────────────────────────────
|
// ── High-level peer request helpers ────────────────────────────────────
|
||||||
|
|
||||||
/// TTL for the [`is_service_active`] cache. Every FIPS dial attempt and
|
|
||||||
/// every warm-tick peer used to spawn up to two `systemctl` subprocesses;
|
|
||||||
/// service state changes on human timescales, so 10s staleness is free.
|
|
||||||
const SERVICE_ACTIVE_TTL_MS: u64 = 10_000;
|
|
||||||
static SERVICE_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
|
||||||
static SERVICE_PROBED_AT_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
||||||
|
|
||||||
/// Quick poll: is the FIPS daemon (archipelago-supervised OR upstream)
|
/// Quick poll: is the FIPS daemon (archipelago-supervised OR upstream)
|
||||||
/// currently `systemctl is-active`? Cached for [`SERVICE_ACTIVE_TTL_MS`];
|
/// currently `systemctl is-active`? Async wrapper intended for the
|
||||||
/// concurrent refreshes are harmless (idempotent probe, last write wins).
|
/// migration call sites; unlike `FipsTransport::is_available` this does
|
||||||
|
/// not maintain a cache, so callers that poll frequently should cache
|
||||||
|
/// themselves.
|
||||||
pub async fn is_service_active() -> bool {
|
pub async fn is_service_active() -> bool {
|
||||||
use std::sync::atomic::Ordering;
|
|
||||||
let now_ms = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_millis() as u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let probed_at = SERVICE_PROBED_AT_MS.load(Ordering::Relaxed);
|
|
||||||
if probed_at != 0 && now_ms.saturating_sub(probed_at) < SERVICE_ACTIVE_TTL_MS {
|
|
||||||
return SERVICE_ACTIVE.load(Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
let mut active = false;
|
|
||||||
for unit in [
|
for unit in [
|
||||||
crate::fips::SERVICE_UNIT,
|
crate::fips::SERVICE_UNIT,
|
||||||
crate::fips::UPSTREAM_SERVICE_UNIT,
|
crate::fips::UPSTREAM_SERVICE_UNIT,
|
||||||
] {
|
] {
|
||||||
if crate::fips::service::unit_state(unit).await == "active" {
|
if crate::fips::service::unit_state(unit).await == "active" {
|
||||||
active = true;
|
return true;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SERVICE_ACTIVE.store(active, Ordering::Relaxed);
|
false
|
||||||
SERVICE_PROBED_AT_MS.store(now_ms, Ordering::Relaxed);
|
|
||||||
active
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builder for a peer request that may be sent over FIPS (preferred) or
|
/// Builder for a peer request that may be sent over FIPS (preferred) or
|
||||||
@ -342,11 +317,6 @@ pub struct PeerRequest<'a> {
|
|||||||
/// large content download needs so its long FIPS transfer isn't truncated.
|
/// large content download needs so its long FIPS transfer isn't truncated.
|
||||||
pub fips_timeout: Option<std::time::Duration>,
|
pub fips_timeout: Option<std::time::Duration>,
|
||||||
pub service: Option<crate::settings::transport::PeerService>,
|
pub service: Option<crate::settings::transport::PeerService>,
|
||||||
/// When set, the transport that actually served this request is written
|
|
||||||
/// to federation storage (`record_peer_transport`, matched by onion) so
|
|
||||||
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
|
|
||||||
/// every caller has a data dir in scope.
|
|
||||||
pub record_data_dir: Option<std::path::PathBuf>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> PeerRequest<'a> {
|
impl<'a> PeerRequest<'a> {
|
||||||
@ -359,31 +329,6 @@ impl<'a> PeerRequest<'a> {
|
|||||||
timeout: std::time::Duration::from_secs(30),
|
timeout: std::time::Duration::from_secs(30),
|
||||||
fips_timeout: None,
|
fips_timeout: None,
|
||||||
service: None,
|
service: None,
|
||||||
record_data_dir: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record the transport that serves this request into federation storage
|
|
||||||
/// (matched by this request's onion host). Best-effort, off the hot path.
|
|
||||||
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
|
|
||||||
self.record_data_dir = Some(data_dir.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_record(&self, kind: crate::transport::TransportKind) {
|
|
||||||
if let Some(dir) = &self.record_data_dir {
|
|
||||||
let dir = dir.clone();
|
|
||||||
let onion = self.onion_host.to_string();
|
|
||||||
let transport = kind.to_string();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ = crate::federation::record_peer_transport(
|
|
||||||
&dir,
|
|
||||||
None,
|
|
||||||
Some(&onion),
|
|
||||||
&transport,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -444,22 +389,8 @@ impl<'a> PeerRequest<'a> {
|
|||||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||||
// fall back. FIPS-only never falls back.
|
// fall back. FIPS-only never falls back.
|
||||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||||
telemetry::record_fips_ok();
|
|
||||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
|
||||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||||
}
|
}
|
||||||
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
|
||||||
FallbackReason::Http404
|
|
||||||
} else {
|
|
||||||
FallbackReason::Http5xx
|
|
||||||
};
|
|
||||||
telemetry::record_fallback(reason);
|
|
||||||
tracing::info!(
|
|
||||||
reason = reason.key(),
|
|
||||||
status = %resp.status(),
|
|
||||||
"FIPS POST {} answered but status triggers Tor fallback",
|
|
||||||
self.path
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
if pref == TransportPref::Fips {
|
if pref == TransportPref::Fips {
|
||||||
@ -471,7 +402,6 @@ impl<'a> PeerRequest<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let resp = self.send_tor_post_json(body).await?;
|
let resp = self.send_tor_post_json(body).await?;
|
||||||
self.spawn_record(crate::transport::TransportKind::Tor);
|
|
||||||
Ok((resp, crate::transport::TransportKind::Tor))
|
Ok((resp, crate::transport::TransportKind::Tor))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -483,22 +413,8 @@ impl<'a> PeerRequest<'a> {
|
|||||||
match self.try_fips_get().await? {
|
match self.try_fips_get().await? {
|
||||||
Some(resp) => {
|
Some(resp) => {
|
||||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||||
telemetry::record_fips_ok();
|
|
||||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
|
||||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||||
}
|
}
|
||||||
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
|
||||||
FallbackReason::Http404
|
|
||||||
} else {
|
|
||||||
FallbackReason::Http5xx
|
|
||||||
};
|
|
||||||
telemetry::record_fallback(reason);
|
|
||||||
tracing::info!(
|
|
||||||
reason = reason.key(),
|
|
||||||
status = %resp.status(),
|
|
||||||
"FIPS GET {} answered but status triggers Tor fallback",
|
|
||||||
self.path
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
if pref == TransportPref::Fips {
|
if pref == TransportPref::Fips {
|
||||||
@ -510,7 +426,6 @@ impl<'a> PeerRequest<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let resp = self.send_tor_get().await?;
|
let resp = self.send_tor_get().await?;
|
||||||
self.spawn_record(crate::transport::TransportKind::Tor);
|
|
||||||
Ok((resp, crate::transport::TransportKind::Tor))
|
Ok((resp, crate::transport::TransportKind::Tor))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -519,127 +434,67 @@ impl<'a> PeerRequest<'a> {
|
|||||||
body: &B,
|
body: &B,
|
||||||
) -> Result<Option<reqwest::Response>> {
|
) -> Result<Option<reqwest::Response>> {
|
||||||
let Some(npub) = self.fips_npub else {
|
let Some(npub) = self.fips_npub else {
|
||||||
telemetry::record_fallback(FallbackReason::NoNpub);
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
if !is_service_active().await {
|
if !is_service_active().await {
|
||||||
telemetry::record_fallback(FallbackReason::ServiceInactive);
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let base = match peer_base_url(npub).await {
|
let base = match peer_base_url(npub).await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
telemetry::record_fallback(FallbackReason::DnsFail);
|
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
|
||||||
tracing::info!(
|
|
||||||
reason = FallbackReason::DnsFail.key(),
|
|
||||||
"FIPS resolve for {} failed: {}, falling back to Tor",
|
|
||||||
npub,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let url = format!("{}{}", base, self.path);
|
let url = format!("{}{}", base, self.path);
|
||||||
let budget = self.fips_attempt_timeout();
|
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||||
// With an explicit fast-fail cap, halve the per-attempt client
|
|
||||||
// timeout so the one-retry path in send_with_retry fits inside the
|
|
||||||
// budget instead of silently doubling it ("fips_timeout(6s)" used
|
|
||||||
// to really mean ~12.6s). Without one (long streaming downloads),
|
|
||||||
// keep the full budget per attempt — the client timeout also
|
|
||||||
// governs body streaming and must not truncate a real transfer.
|
|
||||||
let per_attempt = if self.fips_timeout.is_some() {
|
|
||||||
budget / 2
|
|
||||||
} else {
|
|
||||||
budget
|
|
||||||
};
|
|
||||||
let c = client_with_timeout(per_attempt);
|
|
||||||
let mut rb = c.post(&url).json(body);
|
let mut rb = c.post(&url).json(body);
|
||||||
for (k, v) in &self.headers {
|
for (k, v) in &self.headers {
|
||||||
rb = rb.header(*k, v);
|
rb = rb.header(*k, v);
|
||||||
}
|
}
|
||||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
match send_with_retry(rb).await {
|
||||||
Ok(Ok(r)) => Ok(Some(r)),
|
Ok(r) => Ok(Some(r)),
|
||||||
Ok(Err(e)) => {
|
Err(e) => {
|
||||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
tracing::debug!(
|
||||||
tracing::info!(
|
|
||||||
reason = FallbackReason::ConnectFail.key(),
|
|
||||||
"FIPS POST {} failed after retry: {}, falling back to Tor",
|
"FIPS POST {} failed after retry: {}, falling back to Tor",
|
||||||
url,
|
url,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
Err(_) => {
|
|
||||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
|
||||||
tracing::info!(
|
|
||||||
reason = FallbackReason::ConnectFail.key(),
|
|
||||||
"FIPS POST {} exceeded attempt budget {:?}, falling back to Tor",
|
|
||||||
url,
|
|
||||||
budget
|
|
||||||
);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> {
|
async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> {
|
||||||
let Some(npub) = self.fips_npub else {
|
let Some(npub) = self.fips_npub else {
|
||||||
telemetry::record_fallback(FallbackReason::NoNpub);
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
if !is_service_active().await {
|
if !is_service_active().await {
|
||||||
telemetry::record_fallback(FallbackReason::ServiceInactive);
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let base = match peer_base_url(npub).await {
|
let base = match peer_base_url(npub).await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
telemetry::record_fallback(FallbackReason::DnsFail);
|
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
|
||||||
tracing::info!(
|
|
||||||
reason = FallbackReason::DnsFail.key(),
|
|
||||||
"FIPS resolve for {} failed: {}, falling back to Tor",
|
|
||||||
npub,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let url = format!("{}{}", base, self.path);
|
let url = format!("{}{}", base, self.path);
|
||||||
let budget = self.fips_attempt_timeout();
|
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||||
// Same budget discipline as the POST path: halve per attempt only
|
|
||||||
// under an explicit fast-fail cap; hard-cap the retry sequence.
|
|
||||||
let per_attempt = if self.fips_timeout.is_some() {
|
|
||||||
budget / 2
|
|
||||||
} else {
|
|
||||||
budget
|
|
||||||
};
|
|
||||||
let c = client_with_timeout(per_attempt);
|
|
||||||
let mut rb = c.get(&url);
|
let mut rb = c.get(&url);
|
||||||
for (k, v) in &self.headers {
|
for (k, v) in &self.headers {
|
||||||
rb = rb.header(*k, v);
|
rb = rb.header(*k, v);
|
||||||
}
|
}
|
||||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
match send_with_retry(rb).await {
|
||||||
Ok(Ok(r)) => Ok(Some(r)),
|
Ok(r) => Ok(Some(r)),
|
||||||
Ok(Err(e)) => {
|
Err(e) => {
|
||||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
tracing::debug!(
|
||||||
tracing::info!(
|
|
||||||
reason = FallbackReason::ConnectFail.key(),
|
|
||||||
"FIPS GET {} failed after retry: {}, falling back to Tor",
|
"FIPS GET {} failed after retry: {}, falling back to Tor",
|
||||||
url,
|
url,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
Err(_) => {
|
|
||||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
|
||||||
tracing::info!(
|
|
||||||
reason = FallbackReason::ConnectFail.key(),
|
|
||||||
"FIPS GET {} exceeded attempt budget {:?}, falling back to Tor",
|
|
||||||
url,
|
|
||||||
budget
|
|
||||||
);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,6 @@ pub mod config;
|
|||||||
pub mod dial;
|
pub mod dial;
|
||||||
pub mod iface;
|
pub mod iface;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod telemetry;
|
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -55,8 +54,7 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
|
|||||||
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
|
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let unit = service::activation_unit().await;
|
if let Err(e) = service::activate(SERVICE_UNIT).await {
|
||||||
if let Err(e) = service::activate(unit).await {
|
|
||||||
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
|
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -80,73 +78,25 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
|
|||||||
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
|
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(25));
|
let mut tick = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||||
// Connectivity watcher state: re-apply seed anchors the moment the
|
|
||||||
// anchor link drops (edge) or the data path degrades (dials keep
|
|
||||||
// failing with zero successes), instead of waiting for the 300s
|
|
||||||
// anchor tick. Bounded: at most one re-apply per RE_APPLY_BACKOFF.
|
|
||||||
const RE_APPLY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60);
|
|
||||||
let mut prev_connected: Option<bool> = None;
|
|
||||||
let mut prev_totals = telemetry::totals();
|
|
||||||
let mut last_apply: Option<std::time::Instant> = None;
|
|
||||||
loop {
|
loop {
|
||||||
tick.tick().await;
|
tick.tick().await;
|
||||||
// Bring FIPS up on its own once onboarding has materialised the key.
|
// Bring FIPS up on its own once onboarding has materialised the key.
|
||||||
ensure_activated(&data_dir).await;
|
ensure_activated(&data_dir).await;
|
||||||
if !dial::is_service_active().await {
|
if !dial::is_service_active().await {
|
||||||
prev_connected = None; // daemon restart = fresh edge detection
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Warm the union of federation peers + configured seed
|
|
||||||
// anchors. Warming only federation npubs left the direct
|
|
||||||
// anchors (vps2, LAN peers) to go cold between 300s ticks.
|
|
||||||
let nodes = crate::federation::load_nodes(&data_dir)
|
let nodes = crate::federation::load_nodes(&data_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let seed = anchors::load(&data_dir).await.unwrap_or_default();
|
|
||||||
let mut warm_npubs: std::collections::BTreeSet<String> = nodes
|
|
||||||
.iter()
|
|
||||||
.filter_map(|n| n.fips_npub.clone())
|
|
||||||
.collect();
|
|
||||||
warm_npubs.extend(seed.iter().map(|a| a.npub.clone()));
|
|
||||||
let mut handles = Vec::new();
|
let mut handles = Vec::new();
|
||||||
for npub in warm_npubs {
|
for node in nodes {
|
||||||
// Service-active was checked once above for the whole batch.
|
if let Some(npub) = node.fips_npub.clone() {
|
||||||
handles.push(tokio::spawn(
|
handles.push(tokio::spawn(async move { dial::warm_path(&npub).await }));
|
||||||
async move { dial::warm_path_unchecked(&npub).await },
|
}
|
||||||
));
|
|
||||||
}
|
}
|
||||||
for h in handles {
|
for h in handles {
|
||||||
let _ = h.await;
|
let _ = h.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Connectivity watcher: detect anchor-link loss AND silent
|
|
||||||
// data-path death (daemon reports "connected" but every dial
|
|
||||||
// connect-fails — observed live on .198, 2026-07-27, where the
|
|
||||||
// 300s tick never healed it).
|
|
||||||
let mut anchor_npubs = vec![service::PUBLIC_ANCHOR_NPUB.to_string()];
|
|
||||||
anchor_npubs.extend(seed.iter().map(|a| a.npub.clone()));
|
|
||||||
let (_, connected) = service::peer_connectivity_summary(&anchor_npubs).await;
|
|
||||||
let totals = telemetry::totals();
|
|
||||||
let link_dropped = prev_connected == Some(true) && !connected;
|
|
||||||
let never_connected = prev_connected.is_none() && !connected;
|
|
||||||
let data_path_dead =
|
|
||||||
totals.1.saturating_sub(prev_totals.1) >= 5 && totals.0 == prev_totals.0;
|
|
||||||
prev_connected = Some(connected);
|
|
||||||
prev_totals = totals;
|
|
||||||
|
|
||||||
let backoff_ok = last_apply.is_none_or(|t| t.elapsed() >= RE_APPLY_BACKOFF);
|
|
||||||
if (link_dropped || never_connected || data_path_dead) && backoff_ok && !seed.is_empty()
|
|
||||||
{
|
|
||||||
tracing::info!(
|
|
||||||
link_dropped,
|
|
||||||
never_connected,
|
|
||||||
data_path_dead,
|
|
||||||
"FIPS connectivity degraded — re-applying seed anchors now"
|
|
||||||
);
|
|
||||||
last_apply = Some(std::time::Instant::now());
|
|
||||||
let _ = anchors::apply(&seed).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,16 +32,6 @@ pub async fn unit_state(unit: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether systemd knows about `unit`.
|
|
||||||
pub async fn unit_exists(unit: &str) -> bool {
|
|
||||||
Command::new("systemctl")
|
|
||||||
.args(["cat", unit])
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.map(|out| out.status.success())
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the `fips` debian package is installed on the host.
|
/// Whether the `fips` debian package is installed on the host.
|
||||||
pub async fn package_installed() -> bool {
|
pub async fn package_installed() -> bool {
|
||||||
// dpkg-query -W -f='${Status}' fips → "install ok installed" when present.
|
// dpkg-query -W -f='${Status}' fips → "install ok installed" when present.
|
||||||
@ -141,28 +131,17 @@ done
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve which systemd unit should be started when FIPS is inactive.
|
/// Resolve which systemd unit is actually supervising the fips daemon
|
||||||
/// Newer Archipelago images may ship `archipelago-fips.service`; nodes with
|
/// on this host. Nodes installed from the archipelago ISO run
|
||||||
/// the upstream Debian package may only have `fips.service`. Activation must
|
/// `archipelago-fips.service`; nodes that were apt-installed (or had
|
||||||
/// choose a unit systemd can actually load, otherwise the dashboard repeatedly
|
/// fips running before archipelago took over) may only have the
|
||||||
/// offers an "Activate" action that can never succeed.
|
/// upstream `fips.service`. Restart/Reconnect must operate on whichever
|
||||||
pub async fn activation_unit() -> &'static str {
|
/// one is running, otherwise the UI button is a silent no-op.
|
||||||
if unit_exists(super::SERVICE_UNIT).await {
|
|
||||||
return super::SERVICE_UNIT;
|
|
||||||
}
|
|
||||||
if unit_exists(super::UPSTREAM_SERVICE_UNIT).await {
|
|
||||||
return super::UPSTREAM_SERVICE_UNIT;
|
|
||||||
}
|
|
||||||
super::SERVICE_UNIT
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve which systemd unit is actually supervising the fips daemon on this
|
|
||||||
/// host. Restart/Reconnect must operate on whichever one is running, otherwise
|
|
||||||
/// the UI button is a silent no-op.
|
|
||||||
///
|
///
|
||||||
/// Returns the archipelago-managed unit name if it's active,
|
/// Returns the archipelago-managed unit name if it's active,
|
||||||
/// else the upstream unit name if that's active,
|
/// else the upstream unit name if that's active,
|
||||||
/// else a startable activation unit.
|
/// else the archipelago-managed name as a default (so activate() can
|
||||||
|
/// bring it up).
|
||||||
pub async fn active_unit() -> &'static str {
|
pub async fn active_unit() -> &'static str {
|
||||||
if unit_state(super::SERVICE_UNIT).await == "active" {
|
if unit_state(super::SERVICE_UNIT).await == "active" {
|
||||||
return super::SERVICE_UNIT;
|
return super::SERVICE_UNIT;
|
||||||
@ -170,7 +149,7 @@ pub async fn active_unit() -> &'static str {
|
|||||||
if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" {
|
if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" {
|
||||||
return super::UPSTREAM_SERVICE_UNIT;
|
return super::UPSTREAM_SERVICE_UNIT;
|
||||||
}
|
}
|
||||||
activation_unit().await
|
super::SERVICE_UNIT
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mask(unit: &str) -> Result<()> {
|
pub async fn mask(unit: &str) -> Result<()> {
|
||||||
@ -269,10 +248,4 @@ mod tests {
|
|||||||
// Must not panic regardless of host state.
|
// Must not panic regardless of host state.
|
||||||
let _ = package_installed().await;
|
let _ = package_installed().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_unit_exists_is_bool() {
|
|
||||||
// Must not panic regardless of host state.
|
|
||||||
let _ = unit_exists("archipelago-bogus-test.service").await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,155 +0,0 @@
|
|||||||
//! In-process counters for FIPS dial outcomes.
|
|
||||||
//!
|
|
||||||
//! Every peer dial that could have used FIPS either succeeds over FIPS or
|
|
||||||
//! falls back to Tor for one of six reasons (F1–F6). Before these counters
|
|
||||||
//! existed, fallbacks were `debug!`-only and invisible in production, which
|
|
||||||
//! made "FIPS uptime" unfalsifiable — several paths were 100% Tor for months
|
|
||||||
//! (dead ports, firewalled listeners, allowlist 404s) and nothing surfaced
|
|
||||||
//! it. The counters are process-lifetime (reset on restart) and exposed via
|
|
||||||
//! `fips.status` as `dial_stats`, so a fleet-wide fallback regression shows
|
|
||||||
//! up on the dashboard instead of as vague slowness.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
|
|
||||||
/// Why a FIPS-capable dial fell back to Tor.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum FallbackReason {
|
|
||||||
/// F1 — no FIPS npub known for the peer (never meshed, or pre-npub
|
|
||||||
/// federation record). Expected for non-FIPS peers; high counts here
|
|
||||||
/// mean npub propagation is broken, not the transport.
|
|
||||||
NoNpub,
|
|
||||||
/// F2 — the local FIPS daemon service isn't active.
|
|
||||||
ServiceInactive,
|
|
||||||
/// F3 — the local FIPS DNS resolver couldn't resolve the peer's npub
|
|
||||||
/// (daemon up but peer not in the identity cache / mesh unreachable).
|
|
||||||
DnsFail,
|
|
||||||
/// F4 — TCP/HTTP dial to the peer's ULA failed or exceeded the FIPS
|
|
||||||
/// attempt budget (firewalled :5679, cold hole-punch, peer down).
|
|
||||||
ConnectFail,
|
|
||||||
/// F5 — peer answered over FIPS with 404: its listener doesn't serve
|
|
||||||
/// this path (older build / stricter allowlist).
|
|
||||||
Http404,
|
|
||||||
/// F6 — peer answered over FIPS with a 5xx server error.
|
|
||||||
Http5xx,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FallbackReason {
|
|
||||||
pub fn key(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::NoNpub => "no_npub",
|
|
||||||
Self::ServiceInactive => "service_inactive",
|
|
||||||
Self::DnsFail => "dns_fail",
|
|
||||||
Self::ConnectFail => "connect_fail",
|
|
||||||
Self::Http404 => "http_404",
|
|
||||||
Self::Http5xx => "http_5xx",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static FIPS_OK: AtomicU64 = AtomicU64::new(0);
|
|
||||||
static NO_NPUB: AtomicU64 = AtomicU64::new(0);
|
|
||||||
static SERVICE_INACTIVE: AtomicU64 = AtomicU64::new(0);
|
|
||||||
static DNS_FAIL: AtomicU64 = AtomicU64::new(0);
|
|
||||||
static CONNECT_FAIL: AtomicU64 = AtomicU64::new(0);
|
|
||||||
static HTTP_404: AtomicU64 = AtomicU64::new(0);
|
|
||||||
static HTTP_5XX: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
fn counter(reason: FallbackReason) -> &'static AtomicU64 {
|
|
||||||
match reason {
|
|
||||||
FallbackReason::NoNpub => &NO_NPUB,
|
|
||||||
FallbackReason::ServiceInactive => &SERVICE_INACTIVE,
|
|
||||||
FallbackReason::DnsFail => &DNS_FAIL,
|
|
||||||
FallbackReason::ConnectFail => &CONNECT_FAIL,
|
|
||||||
FallbackReason::Http404 => &HTTP_404,
|
|
||||||
FallbackReason::Http5xx => &HTTP_5XX,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A dial completed over FIPS (any HTTP status that wasn't a fallback
|
|
||||||
/// trigger — the peer was reached on the mesh).
|
|
||||||
pub fn record_fips_ok() {
|
|
||||||
FIPS_OK.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A FIPS-capable dial fell back to Tor.
|
|
||||||
pub fn record_fallback(reason: FallbackReason) {
|
|
||||||
counter(reason).fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `(fips_ok, connect_fail)` totals for the connectivity watcher: a window
|
|
||||||
/// where connect_fail grows while fips_ok doesn't is a degraded data path —
|
|
||||||
/// including the "daemon says connected but packets blackhole" failure the
|
|
||||||
/// link-state check alone can't see (observed live 2026-07-27 on .198).
|
|
||||||
pub fn totals() -> (u64, u64) {
|
|
||||||
(
|
|
||||||
FIPS_OK.load(Ordering::Relaxed),
|
|
||||||
CONNECT_FAIL.load(Ordering::Relaxed),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snapshot for `fips.status` (`dial_stats`). Process-lifetime counts.
|
|
||||||
pub fn snapshot() -> serde_json::Value {
|
|
||||||
let f1 = NO_NPUB.load(Ordering::Relaxed);
|
|
||||||
let f2 = SERVICE_INACTIVE.load(Ordering::Relaxed);
|
|
||||||
let f3 = DNS_FAIL.load(Ordering::Relaxed);
|
|
||||||
let f4 = CONNECT_FAIL.load(Ordering::Relaxed);
|
|
||||||
let f5 = HTTP_404.load(Ordering::Relaxed);
|
|
||||||
let f6 = HTTP_5XX.load(Ordering::Relaxed);
|
|
||||||
serde_json::json!({
|
|
||||||
"fips_ok": FIPS_OK.load(Ordering::Relaxed),
|
|
||||||
"fallbacks": {
|
|
||||||
"no_npub": f1,
|
|
||||||
"service_inactive": f2,
|
|
||||||
"dns_fail": f3,
|
|
||||||
"connect_fail": f4,
|
|
||||||
"http_404": f5,
|
|
||||||
"http_5xx": f6,
|
|
||||||
"total": f1 + f2 + f3 + f4 + f5 + f6,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn snapshot_counts_recorded_events() {
|
|
||||||
// Counters are global; assert deltas rather than absolutes so this
|
|
||||||
// test stays correct alongside any other test that dials.
|
|
||||||
let before = snapshot();
|
|
||||||
record_fips_ok();
|
|
||||||
record_fallback(FallbackReason::ConnectFail);
|
|
||||||
record_fallback(FallbackReason::Http404);
|
|
||||||
let after = snapshot();
|
|
||||||
let d = |v: &serde_json::Value, path: &[&str]| -> u64 {
|
|
||||||
let mut cur = v;
|
|
||||||
for p in path {
|
|
||||||
cur = &cur[p];
|
|
||||||
}
|
|
||||||
cur.as_u64().unwrap()
|
|
||||||
};
|
|
||||||
assert_eq!(d(&after, &["fips_ok"]) - d(&before, &["fips_ok"]), 1);
|
|
||||||
assert_eq!(
|
|
||||||
d(&after, &["fallbacks", "connect_fail"]) - d(&before, &["fallbacks", "connect_fail"]),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
d(&after, &["fallbacks", "http_404"]) - d(&before, &["fallbacks", "http_404"]),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
assert!(d(&after, &["fallbacks", "total"]) >= 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reason_keys_are_stable() {
|
|
||||||
// These strings are the fips.status API surface — renaming one is a
|
|
||||||
// breaking change for the UI.
|
|
||||||
assert_eq!(FallbackReason::NoNpub.key(), "no_npub");
|
|
||||||
assert_eq!(FallbackReason::ServiceInactive.key(), "service_inactive");
|
|
||||||
assert_eq!(FallbackReason::DnsFail.key(), "dns_fail");
|
|
||||||
assert_eq!(FallbackReason::ConnectFail.key(), "connect_fail");
|
|
||||||
assert_eq!(FallbackReason::Http404.key(), "http_404");
|
|
||||||
assert_eq!(FallbackReason::Http5xx.key(), "http_5xx");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -578,7 +578,7 @@ pub(super) async fn handle_identity_received(
|
|||||||
.insert(contact_id, shared_secret);
|
.insert(contact_id, shared_secret);
|
||||||
|
|
||||||
// Update peer record
|
// Update peer record
|
||||||
let mut peer = MeshPeer {
|
let peer = MeshPeer {
|
||||||
contact_id,
|
contact_id,
|
||||||
// .get(): a malformed DID shorter than the "did:key:" prefix must
|
// .get(): a malformed DID shorter than the "did:key:" prefix must
|
||||||
// not panic the listener on a radio-supplied string.
|
// not panic the listener on a radio-supplied string.
|
||||||
@ -607,24 +607,6 @@ pub(super) async fn handle_identity_received(
|
|||||||
let is_new = {
|
let is_new = {
|
||||||
let mut peers = state.peers.write().await;
|
let mut peers = state.peers.write().await;
|
||||||
let is_new = !peers.contains_key(&contact_id);
|
let is_new = !peers.contains_key(&contact_id);
|
||||||
if let Some(existing) = peers.get(&contact_id) {
|
|
||||||
// This id is shared with the federation-seeded row for the same
|
|
||||||
// node (that's the point — identity adverts MERGE, not duplicate).
|
|
||||||
// The wholesale insert below must not stomp the federation row's
|
|
||||||
// real node name with our synthetic "Archy-…" placeholder — with
|
|
||||||
// Reticulum re-emitting identity adverts every announce tick,
|
|
||||||
// that renamed every federated contact once a minute. Same for a
|
|
||||||
// known position: keep it rather than nulling it out.
|
|
||||||
if !existing.advert_name.trim().is_empty()
|
|
||||||
&& !existing.advert_name.starts_with("Archy-")
|
|
||||||
{
|
|
||||||
peer.advert_name = existing.advert_name.clone();
|
|
||||||
}
|
|
||||||
if peer.lat.is_none() {
|
|
||||||
peer.lat = existing.lat;
|
|
||||||
peer.lon = existing.lon;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
peers.insert(contact_id, peer.clone());
|
peers.insert(contact_id, peer.clone());
|
||||||
is_new
|
is_new
|
||||||
};
|
};
|
||||||
|
|||||||
@ -270,7 +270,6 @@ async fn auto_detect_and_open(
|
|||||||
our_x25519_pubkey_hex: &str,
|
our_x25519_pubkey_hex: &str,
|
||||||
device_kind: Option<DeviceType>,
|
device_kind: Option<DeviceType>,
|
||||||
skip_path: Option<&str>,
|
skip_path: Option<&str>,
|
||||||
advert_name: Option<&str>,
|
|
||||||
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||||
let mut paths = super::super::serial::detect_serial_devices().await;
|
let mut paths = super::super::serial::detect_serial_devices().await;
|
||||||
// When falling back from a just-failed preferred path, don't probe that
|
// When falling back from a just-failed preferred path, don't probe that
|
||||||
@ -305,7 +304,6 @@ async fn auto_detect_and_open(
|
|||||||
data_dir,
|
data_dir,
|
||||||
Some(our_ed_pubkey_hex),
|
Some(our_ed_pubkey_hex),
|
||||||
Some(our_x25519_pubkey_hex),
|
Some(our_x25519_pubkey_hex),
|
||||||
advert_name,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@ -467,7 +465,6 @@ async fn open_preferred_path(
|
|||||||
our_ed_pubkey_hex: &str,
|
our_ed_pubkey_hex: &str,
|
||||||
our_x25519_pubkey_hex: &str,
|
our_x25519_pubkey_hex: &str,
|
||||||
device_kind: Option<DeviceType>,
|
device_kind: Option<DeviceType>,
|
||||||
advert_name: Option<&str>,
|
|
||||||
) -> Result<(MeshRadioDevice, DeviceInfo)> {
|
) -> Result<(MeshRadioDevice, DeviceInfo)> {
|
||||||
// Pinned: try only the configured firmware and surface its own error —
|
// Pinned: try only the configured firmware and surface its own error —
|
||||||
// never fall through to (and inject probe bytes into) another firmware's
|
// never fall through to (and inject probe bytes into) another firmware's
|
||||||
@ -500,7 +497,6 @@ async fn open_preferred_path(
|
|||||||
data_dir,
|
data_dir,
|
||||||
Some(our_ed_pubkey_hex),
|
Some(our_ed_pubkey_hex),
|
||||||
Some(our_x25519_pubkey_hex),
|
Some(our_x25519_pubkey_hex),
|
||||||
advert_name,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.context("Could not open preferred path as Reticulum")?;
|
.context("Could not open preferred path as Reticulum")?;
|
||||||
@ -523,7 +519,6 @@ async fn open_preferred_path(
|
|||||||
data_dir,
|
data_dir,
|
||||||
Some(our_ed_pubkey_hex),
|
Some(our_ed_pubkey_hex),
|
||||||
Some(our_x25519_pubkey_hex),
|
Some(our_x25519_pubkey_hex),
|
||||||
advert_name,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@ -561,7 +556,6 @@ async fn open_reticulum_tcp(
|
|||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
our_ed_pubkey_hex: &str,
|
our_ed_pubkey_hex: &str,
|
||||||
our_x25519_pubkey_hex: &str,
|
our_x25519_pubkey_hex: &str,
|
||||||
advert_name: Option<&str>,
|
|
||||||
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||||
let mut dev = match cfg {
|
let mut dev = match cfg {
|
||||||
ReticulumTcpConfig::Server { bind } => ReticulumLink::open_tcp_server(
|
ReticulumTcpConfig::Server { bind } => ReticulumLink::open_tcp_server(
|
||||||
@ -569,7 +563,6 @@ async fn open_reticulum_tcp(
|
|||||||
data_dir,
|
data_dir,
|
||||||
Some(our_ed_pubkey_hex),
|
Some(our_ed_pubkey_hex),
|
||||||
Some(our_x25519_pubkey_hex),
|
Some(our_x25519_pubkey_hex),
|
||||||
advert_name,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.context("Could not open Reticulum TCP server interface")?,
|
.context("Could not open Reticulum TCP server interface")?,
|
||||||
@ -578,7 +571,6 @@ async fn open_reticulum_tcp(
|
|||||||
data_dir,
|
data_dir,
|
||||||
Some(our_ed_pubkey_hex),
|
Some(our_ed_pubkey_hex),
|
||||||
Some(our_x25519_pubkey_hex),
|
Some(our_x25519_pubkey_hex),
|
||||||
advert_name,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.context("Could not open Reticulum TCP client interface")?,
|
.context("Could not open Reticulum TCP client interface")?,
|
||||||
@ -995,93 +987,49 @@ pub(super) async fn run_mesh_session(
|
|||||||
// auto-detect. TCP mode is additive/dev-only; it never changes behavior
|
// auto-detect. TCP mode is additive/dev-only; it never changes behavior
|
||||||
// for existing serial/RNode deployments where `reticulum_tcp` is None.
|
// for existing serial/RNode deployments where `reticulum_tcp` is None.
|
||||||
//
|
//
|
||||||
// The name we present on the mesh: the operator's configured mesh name /
|
|
||||||
// server name, falling back to a DID fragment. Computed BEFORE the open
|
|
||||||
// sequence because Reticulum needs it at daemon-spawn time — the RNS
|
|
||||||
// announce carries it from the very first announce. (Meshcore/Meshtastic
|
|
||||||
// still receive it via set_advert_name after connect, below.)
|
|
||||||
let desired_advert_name: String = match server_name {
|
|
||||||
// Meshcore firmware limits advert names — truncate to 20 chars.
|
|
||||||
Some(name) => name.chars().take(20).collect(),
|
|
||||||
None => format!(
|
|
||||||
"Archy-{}",
|
|
||||||
our_did.chars().skip(8).take(8).collect::<String>()
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
// The whole open sequence runs under PORT_OPEN_LOCK so an RPC probe
|
// The whole open sequence runs under PORT_OPEN_LOCK so an RPC probe
|
||||||
// can't interleave its own handshakes on the same tty (see the lock's
|
// can't interleave its own handshakes on the same tty (see the lock's
|
||||||
// doc comment). Held only until the device is opened, then released.
|
// doc comment). Held only until the device is opened, then released.
|
||||||
//
|
let port_guard = PORT_OPEN_LOCK.lock().await;
|
||||||
// The sequence is raced against the shutdown signal: probes/handshakes
|
let (device_path, mut device, device_info) = if let Some(tcp_cfg) = &reticulum_tcp {
|
||||||
// can take 10s+, and without this a stop() issued mid-probe (config
|
open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await?
|
||||||
// change, disable, rename) always burned the full listener-shutdown
|
} else if let Some(path) = preferred_path {
|
||||||
// timeout and ended in a hard abort — observed live on archi-dev-box
|
match open_preferred_path(
|
||||||
// 2026-07-28. Dropping the open future mid-probe is safe: it holds no
|
path,
|
||||||
// session state yet and the port guard/serial handle close with it.
|
data_dir,
|
||||||
let open_fut = async {
|
our_ed_pubkey_hex,
|
||||||
let port_guard = PORT_OPEN_LOCK.lock().await;
|
our_x25519_pubkey_hex,
|
||||||
let result = if let Some(tcp_cfg) = &reticulum_tcp {
|
device_kind,
|
||||||
open_reticulum_tcp(
|
)
|
||||||
tcp_cfg,
|
.await
|
||||||
data_dir,
|
{
|
||||||
our_ed_pubkey_hex,
|
Ok((dev, info)) => (path.to_string(), dev, info),
|
||||||
our_x25519_pubkey_hex,
|
Err(e) => {
|
||||||
Some(&desired_advert_name),
|
warn!(
|
||||||
)
|
"Preferred path {} probe failed: {} — trying auto-detect",
|
||||||
.await
|
path, e
|
||||||
} else if let Some(path) = preferred_path {
|
);
|
||||||
match open_preferred_path(
|
auto_detect_and_open(
|
||||||
path,
|
data_dir,
|
||||||
data_dir,
|
our_ed_pubkey_hex,
|
||||||
our_ed_pubkey_hex,
|
our_x25519_pubkey_hex,
|
||||||
our_x25519_pubkey_hex,
|
device_kind,
|
||||||
device_kind,
|
Some(path),
|
||||||
Some(&desired_advert_name),
|
)
|
||||||
)
|
.await?
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok((dev, info)) => Ok((path.to_string(), dev, info)),
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
"Preferred path {} probe failed: {} — trying auto-detect",
|
|
||||||
path, e
|
|
||||||
);
|
|
||||||
auto_detect_and_open(
|
|
||||||
data_dir,
|
|
||||||
our_ed_pubkey_hex,
|
|
||||||
our_x25519_pubkey_hex,
|
|
||||||
device_kind,
|
|
||||||
Some(path),
|
|
||||||
Some(&desired_advert_name),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
auto_detect_and_open(
|
|
||||||
data_dir,
|
|
||||||
our_ed_pubkey_hex,
|
|
||||||
our_x25519_pubkey_hex,
|
|
||||||
device_kind,
|
|
||||||
None,
|
|
||||||
Some(&desired_advert_name),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
};
|
|
||||||
drop(port_guard);
|
|
||||||
result
|
|
||||||
};
|
|
||||||
let (device_path, mut device, device_info) = tokio::select! {
|
|
||||||
res = open_fut => res?,
|
|
||||||
_ = shutdown.changed() => {
|
|
||||||
if *shutdown.borrow() {
|
|
||||||
info!("Shutdown requested during device open — ending session");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
anyhow::bail!("shutdown signal changed during device open");
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
auto_detect_and_open(
|
||||||
|
data_dir,
|
||||||
|
our_ed_pubkey_hex,
|
||||||
|
our_x25519_pubkey_hex,
|
||||||
|
device_kind,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
|
drop(port_guard);
|
||||||
|
|
||||||
// Update status
|
// Update status
|
||||||
{
|
{
|
||||||
@ -1228,14 +1176,19 @@ pub(super) async fn run_mesh_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set advert name to the configured mesh/server name (computed above).
|
// Set advert name to the server's human-readable name (e.g. "ThinkPad"),
|
||||||
// Skipped in keep-as-is mode for radio-held names — the radio keeps the
|
// falling back to the DID fragment if no name is configured. Skipped in
|
||||||
// name it came with (already reflected in status from the connect
|
// keep-as-is mode — the radio keeps the name it came with (already
|
||||||
// handshake). Reticulum is exempt from keep-as-is: its display name
|
// reflected in status from the connect handshake).
|
||||||
// lives in OUR daemon (the RNode holds no name), so "keep as is" has
|
if manage_radio {
|
||||||
// nothing to preserve and an unnamed node would be anonymous on RNS.
|
let advert_name = if let Some(name) = server_name {
|
||||||
if manage_radio || matches!(device, MeshRadioDevice::Reticulum(_)) {
|
// Meshcore firmware limits advert names — truncate to 20 chars
|
||||||
if let Err(e) = device.set_advert_name(&desired_advert_name).await {
|
name.chars().take(20).collect::<String>()
|
||||||
|
} else {
|
||||||
|
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
|
||||||
|
format!("Archy-{}", short_did)
|
||||||
|
};
|
||||||
|
if let Err(e) = device.set_advert_name(&advert_name).await {
|
||||||
warn!("Failed to set advert name: {}", e);
|
warn!("Failed to set advert name: {}", e);
|
||||||
} else {
|
} else {
|
||||||
// Reflect the post-set name in MeshStatus too so the UI can filter
|
// Reflect the post-set name in MeshStatus too so the UI can filter
|
||||||
@ -1243,7 +1196,7 @@ pub(super) async fn run_mesh_session(
|
|||||||
// still carries whatever pre-set name the firmware reported and the
|
// still carries whatever pre-set name the firmware reported and the
|
||||||
// self-filter never matches.
|
// self-filter never matches.
|
||||||
let mut status = state.status.write().await;
|
let mut status = state.status.write().await;
|
||||||
status.self_advert_name = Some(desired_advert_name.clone());
|
status.self_advert_name = Some(advert_name.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1536,15 +1489,6 @@ async fn handle_send_command(
|
|||||||
} else {
|
} else {
|
||||||
*consecutive_write_failures = 0;
|
*consecutive_write_failures = 0;
|
||||||
}
|
}
|
||||||
// The self-advert alone is a no-op for discovery on Meshtastic
|
|
||||||
// (heartbeat + time carry no identity) — the NodeInfo broadcast
|
|
||||||
// is what makes peers learn/refresh us. want_response=true so
|
|
||||||
// neighbours answer with their own NodeInfo: the user pressed
|
|
||||||
// Broadcast to be seen AND to see who's out there. No-op on
|
|
||||||
// Meshcore/Reticulum, whose self-advert already carries identity.
|
|
||||||
if let Err(e) = device.send_nodeinfo_advert(true).await {
|
|
||||||
warn!("Failed to send NodeInfo advert: {}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
MeshCommand::RebootRadio { seconds } => {
|
MeshCommand::RebootRadio { seconds } => {
|
||||||
if let Err(e) = device.reboot(seconds).await {
|
if let Err(e) = device.reboot(seconds).await {
|
||||||
|
|||||||
@ -477,31 +477,6 @@ impl Default for MeshConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a mesh config file has ever been written for this node — lets the
|
|
||||||
/// boot path distinguish "operator explicitly disabled mesh" (file exists,
|
|
||||||
/// enabled=false) from "never configured" (no file), which is the only case
|
|
||||||
/// radio auto-enable should touch.
|
|
||||||
pub fn config_file_exists(data_dir: &Path) -> bool {
|
|
||||||
data_dir.join(MESH_CONFIG_FILE).exists()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True when `new` differs from `old` in any field a running mesh session
|
|
||||||
/// captured by value at spawn (device path/kind, advert name, region, PHY
|
|
||||||
/// params, channel, manage_radio, TCP interface) — i.e. when applying `new`
|
|
||||||
/// to a live service requires a listener restart. Fields the session reads
|
|
||||||
/// live from shared state (broadcast flags, assistant settings, steganography
|
|
||||||
/// mode, …) deliberately don't trigger a restart.
|
|
||||||
fn session_config_changed(old: &MeshConfig, new: &MeshConfig) -> bool {
|
|
||||||
old.device_path != new.device_path
|
|
||||||
|| old.device_kind != new.device_kind
|
|
||||||
|| old.advert_name != new.advert_name
|
|
||||||
|| old.lora_region != new.lora_region
|
|
||||||
|| old.lora_radio_params != new.lora_radio_params
|
|
||||||
|| old.channel_name != new.channel_name
|
|
||||||
|| old.manage_radio != new.manage_radio
|
|
||||||
|| old.reticulum_tcp != new.reticulum_tcp
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn load_config(data_dir: &Path) -> Result<MeshConfig> {
|
pub async fn load_config(data_dir: &Path) -> Result<MeshConfig> {
|
||||||
let path = data_dir.join(MESH_CONFIG_FILE);
|
let path = data_dir.join(MESH_CONFIG_FILE);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@ -789,11 +764,7 @@ impl MeshService {
|
|||||||
self.our_ed_pubkey_hex.clone(),
|
self.our_ed_pubkey_hex.clone(),
|
||||||
self.our_x25519_secret,
|
self.our_x25519_secret,
|
||||||
self.our_x25519_pubkey_hex.clone(),
|
self.our_x25519_pubkey_hex.clone(),
|
||||||
// The mesh-page "Name on the mesh" (config.advert_name) wins over
|
self.server_name.clone(),
|
||||||
// the server name — it existed as write-only config with no reader
|
|
||||||
// until this line, which is why renaming on the Mesh page never
|
|
||||||
// changed anything on the air.
|
|
||||||
self.config.advert_name.clone().or_else(|| self.server_name.clone()),
|
|
||||||
self.config.lora_region.clone(),
|
self.config.lora_region.clone(),
|
||||||
self.config.lora_radio_params,
|
self.config.lora_radio_params,
|
||||||
self.config.channel_name.clone(),
|
self.config.channel_name.clone(),
|
||||||
@ -1153,32 +1124,7 @@ impl MeshService {
|
|||||||
let peer = peers
|
let peer = peers
|
||||||
.get(&contact_id)
|
.get(&contact_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Peer not found"))?;
|
.ok_or_else(|| anyhow::anyhow!("Peer not found"))?;
|
||||||
// Cross-transport twin resolution: callers frequently hold the
|
let pubkey_hex = peer
|
||||||
// FEDERATION twin's contact_id (the UI's merged conversation row),
|
|
||||||
// whose pubkey_hex is the Archipelago ed25519 key — NOT a radio
|
|
||||||
// routing key. Sending a Reticulum resource with that prefix fails
|
|
||||||
// with "Unknown Reticulum prefix" (observed live 2026-07-28,
|
|
||||||
// image-over-LoRa to a merged contact). Route via the radio twin —
|
|
||||||
// same arch identity, radio-range id — whose pubkey_hex is the
|
|
||||||
// actual over-the-air routing key (RNS dest hash / firmware key).
|
|
||||||
let radio_peer = if peer.contact_id >= FEDERATION_CONTACT_ID_BASE {
|
|
||||||
peer.arch_pubkey_hex
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|arch| {
|
|
||||||
peers.values().find(|p| {
|
|
||||||
p.contact_id < FEDERATION_CONTACT_ID_BASE
|
|
||||||
&& p.arch_pubkey_hex.as_deref() == Some(arch)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.ok_or_else(|| {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"Peer is federation-only (no radio twin) — not reachable over the radio"
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
} else {
|
|
||||||
peer
|
|
||||||
};
|
|
||||||
let pubkey_hex = radio_peer
|
|
||||||
.pubkey_hex
|
.pubkey_hex
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?;
|
.ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?;
|
||||||
@ -1316,44 +1262,12 @@ impl MeshService {
|
|||||||
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
|
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
};
|
};
|
||||||
// Transport policy: LoRa first when it can actually carry the message,
|
|
||||||
// then FIPS, then Tor. A federation-synthetic id (what the UI's merged
|
|
||||||
// conversation holds) used to ALWAYS take the federation path, even
|
|
||||||
// when the very same node was sitting one LoRa hop away — so chats
|
|
||||||
// between two radio-equipped nodes silently rode FIPS/Tor. If the
|
|
||||||
// federation contact has a REACHABLE radio twin (same archipelago
|
|
||||||
// identity, radio-range id) and the payload fits the radio, skip the
|
|
||||||
// federation branch: the fall-through LoRa path twin-resolves the
|
|
||||||
// routing key via peer_dest_prefix.
|
|
||||||
let device_connected = self.state.status.read().await.device_connected;
|
|
||||||
let radio_twin_reachable = is_federation_synthetic && !exceeds_lora && device_connected && {
|
|
||||||
let peers = self.state.peers.read().await;
|
|
||||||
peers
|
|
||||||
.get(&contact_id)
|
|
||||||
.and_then(|p| p.arch_pubkey_hex.clone())
|
|
||||||
.map(|arch| {
|
|
||||||
peers.values().any(|p| {
|
|
||||||
p.contact_id < FEDERATION_CONTACT_ID_BASE
|
|
||||||
&& p.reachable
|
|
||||||
&& p.arch_pubkey_hex.as_deref() == Some(arch.as_str())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.unwrap_or(false)
|
|
||||||
};
|
|
||||||
let mesh_only_mode = load_config(&self.data_dir)
|
let mesh_only_mode = load_config(&self.data_dir)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|cfg| cfg.mesh_only_mode)
|
.and_then(|cfg| cfg.mesh_only_mode)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if radio_twin_reachable && !mesh_only_mode {
|
|
||||||
tracing::info!(
|
|
||||||
contact_id,
|
|
||||||
bytes = wire.len(),
|
|
||||||
"Radio-first routing: federation contact has a reachable radio twin — sending over LoRa"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if !mesh_only_mode
|
if !mesh_only_mode
|
||||||
&& !radio_twin_reachable
|
|
||||||
&& (is_federation_synthetic || exceeds_lora || radio_federated_unreachable)
|
&& (is_federation_synthetic || exceeds_lora || radio_federated_unreachable)
|
||||||
{
|
{
|
||||||
// Resolve the peer's pubkey/did. Prefer the live mesh peer table,
|
// Resolve the peer's pubkey/did. Prefer the live mesh peer table,
|
||||||
@ -2177,7 +2091,6 @@ impl MeshService {
|
|||||||
save_config(&self.data_dir, &config).await?;
|
save_config(&self.data_dir, &config).await?;
|
||||||
|
|
||||||
let was_enabled = self.config.enabled;
|
let was_enabled = self.config.enabled;
|
||||||
let needs_session_restart = session_config_changed(&self.config, &config);
|
|
||||||
self.config = config.clone();
|
self.config = config.clone();
|
||||||
|
|
||||||
// Update the status to reflect new config
|
// Update the status to reflect new config
|
||||||
@ -2202,31 +2115,11 @@ impl MeshService {
|
|||||||
status.firmware_version = None;
|
status.firmware_version = None;
|
||||||
status.self_node_id = None;
|
status.self_node_id = None;
|
||||||
status.peer_count = 0;
|
status.peer_count = 0;
|
||||||
} else if config.enabled && was_enabled && needs_session_restart {
|
|
||||||
info!("Mesh session config changed — restarting listener to apply");
|
|
||||||
self.stop().await;
|
|
||||||
self.start()?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The service's current (last-applied) config.
|
|
||||||
pub fn config(&self) -> &MeshConfig {
|
|
||||||
&self.config
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restart the listener (if running) so it picks up out-of-band state a
|
|
||||||
/// spawn captured by value — currently the server name pushed by
|
|
||||||
/// `server.set-name`.
|
|
||||||
pub async fn restart_listener_if_running(&mut self) -> Result<()> {
|
|
||||||
if self.listener_handle.is_some() {
|
|
||||||
self.stop().await;
|
|
||||||
self.start()?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a reference to shared state (for RPC handlers).
|
/// Get a reference to shared state (for RPC handlers).
|
||||||
pub fn shared_state(&self) -> Arc<MeshState> {
|
pub fn shared_state(&self) -> Arc<MeshState> {
|
||||||
Arc::clone(&self.state)
|
Arc::clone(&self.state)
|
||||||
@ -2348,46 +2241,6 @@ async fn bitcoin_rpc_getblockheader_by_height(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn session_config_change_detection() {
|
|
||||||
let base = MeshConfig::default();
|
|
||||||
|
|
||||||
// Same config → no restart.
|
|
||||||
assert!(!session_config_changed(&base, &base.clone()));
|
|
||||||
|
|
||||||
// Every session-captured field individually triggers a restart.
|
|
||||||
let mut c = base.clone();
|
|
||||||
c.device_kind = Some(types::DeviceType::Reticulum);
|
|
||||||
assert!(session_config_changed(&base, &c));
|
|
||||||
|
|
||||||
let mut c = base.clone();
|
|
||||||
c.device_path = Some("/dev/ttyUSB0".into());
|
|
||||||
assert!(session_config_changed(&base, &c));
|
|
||||||
|
|
||||||
let mut c = base.clone();
|
|
||||||
c.advert_name = Some("RNode Shaza".into());
|
|
||||||
assert!(session_config_changed(&base, &c));
|
|
||||||
|
|
||||||
let mut c = base.clone();
|
|
||||||
c.manage_radio = !base.manage_radio;
|
|
||||||
assert!(session_config_changed(&base, &c));
|
|
||||||
|
|
||||||
let mut c = base.clone();
|
|
||||||
c.lora_region = Some("EU_868".into());
|
|
||||||
assert!(session_config_changed(&base, &c));
|
|
||||||
|
|
||||||
let mut c = base.clone();
|
|
||||||
c.channel_name = Some("private-net".into());
|
|
||||||
assert!(session_config_changed(&base, &c));
|
|
||||||
|
|
||||||
// Live-read fields must NOT force a session restart.
|
|
||||||
let mut c = base.clone();
|
|
||||||
c.broadcast_identity = !base.broadcast_identity;
|
|
||||||
c.announce_block_headers = !base.announce_block_headers;
|
|
||||||
c.assistant_enabled = !base.assistant_enabled;
|
|
||||||
assert!(!session_config_changed(&base, &c));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer {
|
fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer {
|
||||||
MeshPeer {
|
MeshPeer {
|
||||||
contact_id,
|
contact_id,
|
||||||
|
|||||||
@ -121,7 +121,6 @@ fn daemon_command(
|
|||||||
identity_key: &Path,
|
identity_key: &Path,
|
||||||
archy_ed_pubkey_hex: Option<&str>,
|
archy_ed_pubkey_hex: Option<&str>,
|
||||||
archy_x25519_pubkey_hex: Option<&str>,
|
archy_x25519_pubkey_hex: Option<&str>,
|
||||||
display_name: Option<&str>,
|
|
||||||
) -> Command {
|
) -> Command {
|
||||||
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN")
|
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN")
|
||||||
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string());
|
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string());
|
||||||
@ -160,15 +159,6 @@ fn daemon_command(
|
|||||||
.arg("--archy-x25519-pubkey-hex")
|
.arg("--archy-x25519-pubkey-hex")
|
||||||
.arg(x);
|
.arg(x);
|
||||||
}
|
}
|
||||||
// The RNS-visible display name (what Sideband/NomadNet/other archy nodes
|
|
||||||
// show for us). Without this the daemon falls back to its argparse
|
|
||||||
// default and every archy node announces the same anonymous name.
|
|
||||||
if let Some(name) = display_name {
|
|
||||||
let name = name.trim();
|
|
||||||
if !name.is_empty() {
|
|
||||||
cmd.arg("--display-name").arg(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Run the daemon as its own process-group leader. The packaged binary is
|
// Run the daemon as its own process-group leader. The packaged binary is
|
||||||
// a PyInstaller one-file bootloader that forks the real Python process;
|
// a PyInstaller one-file bootloader that forks the real Python process;
|
||||||
// making it a group leader lets shutdown signal the WHOLE group so the
|
// making it a group leader lets shutdown signal the WHOLE group so the
|
||||||
@ -217,10 +207,6 @@ struct ReticulumPeer {
|
|||||||
/// `bind_federation_twins`, which those two transports rely on instead).
|
/// `bind_federation_twins`, which those two transports rely on instead).
|
||||||
arch_pubkey_hex: Option<String>,
|
arch_pubkey_hex: Option<String>,
|
||||||
reachable: bool,
|
reachable: bool,
|
||||||
/// Unix time of the last announce heard from this peer over the air.
|
|
||||||
/// In-memory only (a persisted value would be stale by definition) —
|
|
||||||
/// `0` after a restart until the peer re-announces.
|
|
||||||
last_advert_at: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key,
|
/// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key,
|
||||||
@ -259,11 +245,6 @@ pub struct ReticulumLink {
|
|||||||
/// matching `resource_progress`/`resource_sent`/`resource_failed` events
|
/// matching `resource_progress`/`resource_sent`/`resource_failed` events
|
||||||
/// back to a log line; sends are fire-and-forget (see `send_resource`).
|
/// back to a log line; sends are fire-and-forget (see `send_resource`).
|
||||||
resource_id_counter: u64,
|
resource_id_counter: u64,
|
||||||
/// Set when the daemon's RPC socket closes or its process exits. Once
|
|
||||||
/// true, `try_recv_frame` returns an error so the session loop tears
|
|
||||||
/// down and the outer reconnect loop respawns the daemon — without this
|
|
||||||
/// a dead daemon was invisible until the 30-minute RX-stall watchdog.
|
|
||||||
daemon_gone: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReticulumLink {
|
impl ReticulumLink {
|
||||||
@ -288,7 +269,6 @@ impl ReticulumLink {
|
|||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
our_ed_pubkey_hex: Option<&str>,
|
our_ed_pubkey_hex: Option<&str>,
|
||||||
our_x25519_pubkey_hex: Option<&str>,
|
our_x25519_pubkey_hex: Option<&str>,
|
||||||
display_name: Option<&str>,
|
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
probe_rnode(path)
|
probe_rnode(path)
|
||||||
.await
|
.await
|
||||||
@ -298,7 +278,6 @@ impl ReticulumLink {
|
|||||||
data_dir,
|
data_dir,
|
||||||
our_ed_pubkey_hex,
|
our_ed_pubkey_hex,
|
||||||
our_x25519_pubkey_hex,
|
our_x25519_pubkey_hex,
|
||||||
display_name,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -311,7 +290,6 @@ impl ReticulumLink {
|
|||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
our_ed_pubkey_hex: Option<&str>,
|
our_ed_pubkey_hex: Option<&str>,
|
||||||
our_x25519_pubkey_hex: Option<&str>,
|
our_x25519_pubkey_hex: Option<&str>,
|
||||||
display_name: Option<&str>,
|
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind);
|
let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind);
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
@ -324,7 +302,6 @@ impl ReticulumLink {
|
|||||||
data_dir,
|
data_dir,
|
||||||
our_ed_pubkey_hex,
|
our_ed_pubkey_hex,
|
||||||
our_x25519_pubkey_hex,
|
our_x25519_pubkey_hex,
|
||||||
display_name,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -336,7 +313,6 @@ impl ReticulumLink {
|
|||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
our_ed_pubkey_hex: Option<&str>,
|
our_ed_pubkey_hex: Option<&str>,
|
||||||
our_x25519_pubkey_hex: Option<&str>,
|
our_x25519_pubkey_hex: Option<&str>,
|
||||||
display_name: Option<&str>,
|
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
!targets.is_empty(),
|
!targets.is_empty(),
|
||||||
@ -347,7 +323,6 @@ impl ReticulumLink {
|
|||||||
data_dir,
|
data_dir,
|
||||||
our_ed_pubkey_hex,
|
our_ed_pubkey_hex,
|
||||||
our_x25519_pubkey_hex,
|
our_x25519_pubkey_hex,
|
||||||
display_name,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -357,7 +332,6 @@ impl ReticulumLink {
|
|||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
our_ed_pubkey_hex: Option<&str>,
|
our_ed_pubkey_hex: Option<&str>,
|
||||||
our_x25519_pubkey_hex: Option<&str>,
|
our_x25519_pubkey_hex: Option<&str>,
|
||||||
display_name: Option<&str>,
|
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
// Keep the RPC socket under the archipelago-owned data dir (not the
|
// Keep the RPC socket under the archipelago-owned data dir (not the
|
||||||
// shared system temp dir) so its access is bounded by the same
|
// shared system temp dir) so its access is bounded by the same
|
||||||
@ -405,7 +379,6 @@ impl ReticulumLink {
|
|||||||
&identity_key,
|
&identity_key,
|
||||||
our_ed_pubkey_hex,
|
our_ed_pubkey_hex,
|
||||||
our_x25519_pubkey_hex,
|
our_x25519_pubkey_hex,
|
||||||
display_name,
|
|
||||||
);
|
);
|
||||||
cmd.env("TMPDIR", &tmp_dir);
|
cmd.env("TMPDIR", &tmp_dir);
|
||||||
let child = cmd
|
let child = cmd
|
||||||
@ -477,7 +450,6 @@ impl ReticulumLink {
|
|||||||
peers_file: runtime_dir.join("peers.json"),
|
peers_file: runtime_dir.join("peers.json"),
|
||||||
inbound: std::collections::VecDeque::new(),
|
inbound: std::collections::VecDeque::new(),
|
||||||
resource_id_counter: 0,
|
resource_id_counter: 0,
|
||||||
daemon_gone: false,
|
|
||||||
};
|
};
|
||||||
link.load_persisted_peers();
|
link.load_persisted_peers();
|
||||||
Ok(link)
|
Ok(link)
|
||||||
@ -500,53 +472,21 @@ impl ReticulumLink {
|
|||||||
};
|
};
|
||||||
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
|
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
|
||||||
self.prefix_to_hash.insert(prefix, hash);
|
self.prefix_to_hash.insert(prefix, hash);
|
||||||
// Heal names persisted by pre-2026-07-28 builds, which could
|
|
||||||
// store a raw `ARCHY:…` identity blob as the display name (seen
|
|
||||||
// live on archi-dev-box). Blob-only announces assert no name, so
|
|
||||||
// nothing would ever overwrite it — swap in the placeholder.
|
|
||||||
let display_name = if p.display_name.starts_with("ARCHY:") {
|
|
||||||
format!("Reticulum {}", hex::encode(&hash[..4]))
|
|
||||||
} else {
|
|
||||||
p.display_name
|
|
||||||
};
|
|
||||||
self.peers.insert(
|
self.peers.insert(
|
||||||
hash,
|
hash,
|
||||||
ReticulumPeer {
|
ReticulumPeer {
|
||||||
dest_hash: hash,
|
dest_hash: hash,
|
||||||
display_name,
|
display_name: p.display_name,
|
||||||
arch_pubkey_hex: p.arch_pubkey_hex,
|
arch_pubkey_hex: p.arch_pubkey_hex,
|
||||||
// Reachability is a live property, not a persisted fact —
|
// Reachability is a live property, not a persisted fact —
|
||||||
// start conservative and let the first real event refresh it.
|
// start conservative and let the first real event refresh it.
|
||||||
reachable: false,
|
reachable: false,
|
||||||
last_advert_at: 0,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
info!(count = self.peers.len(), "Loaded persisted Reticulum peers");
|
info!(count = self.peers.len(), "Loaded persisted Reticulum peers");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a caller-supplied 6-byte routing prefix to a full 16-byte RNS
|
|
||||||
/// destination hash. Two shapes arrive here depending on how the contact
|
|
||||||
/// record was born (observed live 2026-07-28, image-to-merged-contact):
|
|
||||||
/// 1. the peer's RNS dest-hash prefix (contacts created from an RNS
|
|
||||||
/// announce) — direct `prefix_to_hash` hit;
|
|
||||||
/// 2. the peer's Archipelago ed25519 pubkey prefix (radio twins bound
|
|
||||||
/// via the ARCHY announce blob store the arch key as `pubkey_hex`) —
|
|
||||||
/// no dest-hash match possible, so fall back to scanning peers whose
|
|
||||||
/// announce-bound `arch_pubkey_hex` starts with the prefix.
|
|
||||||
fn resolve_dest_hash(&self, prefix: &[u8; 6]) -> Option<[u8; 16]> {
|
|
||||||
if let Some(hash) = self.prefix_to_hash.get(prefix) {
|
|
||||||
return Some(*hash);
|
|
||||||
}
|
|
||||||
let hex_prefix = hex::encode(prefix);
|
|
||||||
self.peers.values().find_map(|p| {
|
|
||||||
p.arch_pubkey_hex
|
|
||||||
.as_deref()
|
|
||||||
.filter(|arch| arch.starts_with(&hex_prefix))
|
|
||||||
.map(|_| p.dest_hash)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort sync write of the current peer table — called after any
|
/// Best-effort sync write of the current peer table — called after any
|
||||||
/// insert that adds/renames a peer. Infrequent (announces/first-contact,
|
/// insert that adds/renames a peer. Infrequent (announces/first-contact,
|
||||||
/// not per-message) so a blocking write here is a fine trade for keeping
|
/// not per-message) so a blocking write here is a fine trade for keeping
|
||||||
@ -593,12 +533,10 @@ impl ReticulumLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||||
// Live rename: the daemon's `set_name` verb updates the LXMF delivery
|
// The daemon's display_name is fixed at spawn time (CLI arg); changing
|
||||||
// destination's display_name and re-announces, so peers pick the new
|
// it live would require an RPC verb we haven't added. Track locally so
|
||||||
// name up on their next announce receipt. Also tracked locally so
|
// `advert_name()` reflects the caller's intent even though the
|
||||||
// `advert_name()` reflects it immediately.
|
// RNS-visible name doesn't change until the daemon restarts.
|
||||||
self.send_rpc(serde_json::json!({"cmd": "set_name", "name": name}))
|
|
||||||
.await?;
|
|
||||||
self.display_name = Some(name.to_string());
|
self.display_name = Some(name.to_string());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -623,12 +561,16 @@ impl ReticulumLink {
|
|||||||
dest_pubkey_prefix: &[u8; 6],
|
dest_pubkey_prefix: &[u8; 6],
|
||||||
payload: &[u8],
|
payload: &[u8],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let dest_hash = self.resolve_dest_hash(dest_pubkey_prefix).with_context(|| {
|
let dest_hash = self
|
||||||
format!(
|
.prefix_to_hash
|
||||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
.get(dest_pubkey_prefix)
|
||||||
hex::encode(dest_pubkey_prefix)
|
.copied()
|
||||||
)
|
.with_context(|| {
|
||||||
})?;
|
format!(
|
||||||
|
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||||
|
hex::encode(dest_pubkey_prefix)
|
||||||
|
)
|
||||||
|
})?;
|
||||||
// Typed-envelope payloads (ReadReceipt/Reaction/etc. — anything small
|
// Typed-envelope payloads (ReadReceipt/Reaction/etc. — anything small
|
||||||
// enough for the single-frame path) are raw binary CBOR, not text.
|
// enough for the single-frame path) are raw binary CBOR, not text.
|
||||||
// `from_utf8_lossy` would irreversibly mangle them since `content`
|
// `from_utf8_lossy` would irreversibly mangle them since `content`
|
||||||
@ -663,12 +605,16 @@ impl ReticulumLink {
|
|||||||
caption: Option<&str>,
|
caption: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
let dest_hash = self.resolve_dest_hash(dest_pubkey_prefix).with_context(|| {
|
let dest_hash = self
|
||||||
format!(
|
.prefix_to_hash
|
||||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
.get(dest_pubkey_prefix)
|
||||||
hex::encode(dest_pubkey_prefix)
|
.copied()
|
||||||
)
|
.with_context(|| {
|
||||||
})?;
|
format!(
|
||||||
|
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||||
|
hex::encode(dest_pubkey_prefix)
|
||||||
|
)
|
||||||
|
})?;
|
||||||
self.send_rpc(serde_json::json!({
|
self.send_rpc(serde_json::json!({
|
||||||
"cmd": "send",
|
"cmd": "send",
|
||||||
"dest_hash": hex::encode(dest_hash),
|
"dest_hash": hex::encode(dest_hash),
|
||||||
@ -691,12 +637,16 @@ impl ReticulumLink {
|
|||||||
/// `handle_event`, not awaited here.
|
/// `handle_event`, not awaited here.
|
||||||
pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
|
pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
let dest_hash = self.resolve_dest_hash(dest_pubkey_prefix).with_context(|| {
|
let dest_hash = self
|
||||||
format!(
|
.prefix_to_hash
|
||||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
.get(dest_pubkey_prefix)
|
||||||
hex::encode(dest_pubkey_prefix)
|
.copied()
|
||||||
)
|
.with_context(|| {
|
||||||
})?;
|
format!(
|
||||||
|
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||||
|
hex::encode(dest_pubkey_prefix)
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let req_id = self.next_resource_id();
|
let req_id = self.next_resource_id();
|
||||||
self.send_rpc(serde_json::json!({
|
self.send_rpc(serde_json::json!({
|
||||||
"cmd": "send_resource",
|
"cmd": "send_resource",
|
||||||
@ -735,7 +685,7 @@ impl ReticulumLink {
|
|||||||
.map(|p| ParsedContact {
|
.map(|p| ParsedContact {
|
||||||
public_key_hex: hex::encode(p.dest_hash),
|
public_key_hex: hex::encode(p.dest_hash),
|
||||||
advert_name: p.display_name.clone(),
|
advert_name: p.display_name.clone(),
|
||||||
last_advert: p.last_advert_at as u32,
|
last_advert: 0,
|
||||||
// Deliberately not 1 ("friend"/meshcore type), so the
|
// Deliberately not 1 ("friend"/meshcore type), so the
|
||||||
// meshcore-only auto-heal `reset_contact_path` loop in
|
// meshcore-only auto-heal `reset_contact_path` loop in
|
||||||
// `refresh_contacts` (session.rs) skips these — RNS does its
|
// `refresh_contacts` (session.rs) skips these — RNS does its
|
||||||
@ -768,12 +718,6 @@ impl ReticulumLink {
|
|||||||
|
|
||||||
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
|
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
|
||||||
self.drain_events().await;
|
self.drain_events().await;
|
||||||
if self.daemon_gone {
|
|
||||||
// Surface the dead daemon as a hard error so run_mesh_session
|
|
||||||
// bails and the outer reconnect loop respawns it, instead of
|
|
||||||
// idling on an empty queue until the RX-stall watchdog fires.
|
|
||||||
anyhow::bail!("reticulum-daemon is gone (process exited or RPC socket closed)");
|
|
||||||
}
|
|
||||||
Ok(self.inbound.pop_front())
|
Ok(self.inbound.pop_front())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -799,15 +743,6 @@ impl ReticulumLink {
|
|||||||
/// Drain any buffered daemon events (non-blocking) and translate them into
|
/// Drain any buffered daemon events (non-blocking) and translate them into
|
||||||
/// peer-table updates / synthetic InboundFrames.
|
/// peer-table updates / synthetic InboundFrames.
|
||||||
async fn drain_events(&mut self) {
|
async fn drain_events(&mut self) {
|
||||||
// A daemon that died without closing the socket cleanly (SIGKILL,
|
|
||||||
// OOM) leaves the socket readable-with-EOF or just silent — poll the
|
|
||||||
// child's exit status too so death is never mistaken for quiet.
|
|
||||||
if !self.daemon_gone {
|
|
||||||
if let Ok(Some(status)) = self.child.try_wait() {
|
|
||||||
warn!(%status, "reticulum-daemon process exited");
|
|
||||||
self.daemon_gone = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loop {
|
loop {
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
let read =
|
let read =
|
||||||
@ -815,16 +750,10 @@ impl ReticulumLink {
|
|||||||
.await;
|
.await;
|
||||||
let n = match read {
|
let n = match read {
|
||||||
Ok(Ok(n)) => n,
|
Ok(Ok(n)) => n,
|
||||||
Ok(Err(e)) => {
|
_ => break, // timeout (no data) or read error — stop draining
|
||||||
warn!("Reticulum daemon RPC read failed: {}", e);
|
|
||||||
self.daemon_gone = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(_) => break, // timeout — no data buffered
|
|
||||||
};
|
};
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
warn!("Reticulum daemon RPC connection closed");
|
warn!("Reticulum daemon RPC connection closed");
|
||||||
self.daemon_gone = true;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let Ok(ev) = serde_json::from_str::<Value>(line.trim()) else {
|
let Ok(ev) = serde_json::from_str::<Value>(line.trim()) else {
|
||||||
@ -846,23 +775,6 @@ impl ReticulumLink {
|
|||||||
};
|
};
|
||||||
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
|
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
|
||||||
self.prefix_to_hash.insert(prefix, hash);
|
self.prefix_to_hash.insert(prefix, hash);
|
||||||
// Current daemons decode the LXMF announce app_data themselves
|
|
||||||
// and hand us clean fields: `display_name` (LXMF-standard
|
|
||||||
// msgpack name, Sideband-interoperable) and `archy_blob` (the
|
|
||||||
// `ARCHY:n:` identity string, carried as an extra msgpack list
|
|
||||||
// element stock clients ignore). The raw `app_data` text path
|
|
||||||
// below remains for announces from pre-upgrade archy nodes,
|
|
||||||
// whose app_data was EITHER the blob OR a bare-utf8 name.
|
|
||||||
let explicit_name = ev
|
|
||||||
.get("display_name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty());
|
|
||||||
let explicit_blob = ev
|
|
||||||
.get("archy_blob")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string)
|
|
||||||
.filter(|s| !s.is_empty());
|
|
||||||
let app_data_text = ev
|
let app_data_text = ev
|
||||||
.get("app_data")
|
.get("app_data")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@ -882,17 +794,12 @@ impl ReticulumLink {
|
|||||||
// now carry the same `arch_pubkey_hex`, instead of relying on
|
// now carry the same `arch_pubkey_hex`, instead of relying on
|
||||||
// `bind_federation_twins`'s advert_name matching, which never
|
// `bind_federation_twins`'s advert_name matching, which never
|
||||||
// matches here — see `display_name` below.
|
// matches here — see `display_name` below.
|
||||||
let legacy_identity = app_data_text
|
let parsed_identity = app_data_text
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(protocol::parse_identity_broadcast);
|
.and_then(protocol::parse_identity_broadcast);
|
||||||
let is_legacy_blob = legacy_identity.is_some();
|
let is_identity_blob = parsed_identity.is_some();
|
||||||
let identity_blob_text = explicit_blob.or_else(|| {
|
if is_identity_blob {
|
||||||
app_data_text.clone().filter(|_| is_legacy_blob)
|
let text = app_data_text.clone().unwrap();
|
||||||
});
|
|
||||||
let parsed_identity = identity_blob_text
|
|
||||||
.as_deref()
|
|
||||||
.and_then(protocol::parse_identity_broadcast);
|
|
||||||
if let Some(text) = identity_blob_text.as_deref().filter(|_| parsed_identity.is_some()) {
|
|
||||||
let mut data = Vec::with_capacity(7 + text.len());
|
let mut data = Vec::with_capacity(7 + text.len());
|
||||||
data.push(0); // channel index — unused by the identity path
|
data.push(0); // channel index — unused by the identity path
|
||||||
data.extend_from_slice(&prefix);
|
data.extend_from_slice(&prefix);
|
||||||
@ -905,31 +812,23 @@ impl ReticulumLink {
|
|||||||
}
|
}
|
||||||
let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey);
|
let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey);
|
||||||
|
|
||||||
let announced_name =
|
let display_name = app_data_text
|
||||||
pick_announced_name(explicit_name, app_data_text, is_legacy_blob);
|
.filter(|_| !is_identity_blob)
|
||||||
let now = std::time::SystemTime::now()
|
.unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4])));
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
self.peers
|
self.peers
|
||||||
.entry(hash)
|
.entry(hash)
|
||||||
.and_modify(|p| {
|
.and_modify(|p| {
|
||||||
if let Some(name) = announced_name.clone() {
|
p.display_name = display_name.clone();
|
||||||
p.display_name = name;
|
|
||||||
}
|
|
||||||
p.reachable = true;
|
p.reachable = true;
|
||||||
p.last_advert_at = now;
|
|
||||||
if arch_pubkey_hex.is_some() {
|
if arch_pubkey_hex.is_some() {
|
||||||
p.arch_pubkey_hex = arch_pubkey_hex.clone();
|
p.arch_pubkey_hex = arch_pubkey_hex.clone();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.or_insert_with(|| ReticulumPeer {
|
.or_insert(ReticulumPeer {
|
||||||
dest_hash: hash,
|
dest_hash: hash,
|
||||||
display_name: announced_name
|
display_name,
|
||||||
.unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4]))),
|
|
||||||
arch_pubkey_hex,
|
arch_pubkey_hex,
|
||||||
reachable: true,
|
reachable: true,
|
||||||
last_advert_at: now,
|
|
||||||
});
|
});
|
||||||
self.persist_peers();
|
self.persist_peers();
|
||||||
}
|
}
|
||||||
@ -945,24 +844,16 @@ impl ReticulumLink {
|
|||||||
// A peer that messages us without ever announcing still needs
|
// A peer that messages us without ever announcing still needs
|
||||||
// to survive a restart — give it a placeholder name (the real
|
// to survive a restart — give it a placeholder name (the real
|
||||||
// one, if any, arrives via a later "announce" and overwrites
|
// one, if any, arrives via a later "announce" and overwrites
|
||||||
// this) so its routing entry alone doesn't get lost. An
|
// this) so its routing entry alone doesn't get lost.
|
||||||
// existing entry is proof of life too: mark it reachable so a
|
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash)
|
||||||
// restart-restored (reachable=false) peer that DMs us doesn't
|
{
|
||||||
// stay red-dotted until its next announce.
|
e.insert(ReticulumPeer {
|
||||||
match self.peers.entry(source_hash) {
|
dest_hash: source_hash,
|
||||||
std::collections::hash_map::Entry::Vacant(e) => {
|
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
||||||
e.insert(ReticulumPeer {
|
arch_pubkey_hex: None,
|
||||||
dest_hash: source_hash,
|
reachable: true,
|
||||||
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
});
|
||||||
arch_pubkey_hex: None,
|
self.persist_peers();
|
||||||
reachable: true,
|
|
||||||
last_advert_at: 0,
|
|
||||||
});
|
|
||||||
self.persist_peers();
|
|
||||||
}
|
|
||||||
std::collections::hash_map::Entry::Occupied(mut e) => {
|
|
||||||
e.get_mut().reachable = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A stock LXMF client (Sideband/NomadNet — not an archy peer)
|
// A stock LXMF client (Sideband/NomadNet — not an archy peer)
|
||||||
@ -1033,37 +924,23 @@ impl ReticulumLink {
|
|||||||
.push_back(build_synthetic_frame(&prefix, &content));
|
.push_back(build_synthetic_frame(&prefix, &content));
|
||||||
}
|
}
|
||||||
Some("resource_recv") => {
|
Some("resource_recv") => {
|
||||||
let source_hex = ev
|
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else {
|
||||||
.get("source_hash")
|
return;
|
||||||
.and_then(Value::as_str)
|
};
|
||||||
.unwrap_or_default();
|
|
||||||
let Ok(source_hash) = parse_hash16(source_hex) else {
|
let Ok(source_hash) = parse_hash16(source_hex) else {
|
||||||
// An empty source_hash means the sender's link wasn't
|
|
||||||
// identified (pre-identify daemon build on the far end) —
|
|
||||||
// the blob is undeliverable without attribution, but say
|
|
||||||
// so instead of vanishing it.
|
|
||||||
warn!(
|
|
||||||
source = source_hex,
|
|
||||||
"Dropping inbound Reticulum resource without valid source identity"
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
|
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
|
||||||
self.prefix_to_hash.insert(prefix, source_hash);
|
self.prefix_to_hash.insert(prefix, source_hash);
|
||||||
match self.peers.entry(source_hash) {
|
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash)
|
||||||
std::collections::hash_map::Entry::Vacant(e) => {
|
{
|
||||||
e.insert(ReticulumPeer {
|
e.insert(ReticulumPeer {
|
||||||
dest_hash: source_hash,
|
dest_hash: source_hash,
|
||||||
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
||||||
arch_pubkey_hex: None,
|
arch_pubkey_hex: None,
|
||||||
reachable: true,
|
reachable: true,
|
||||||
last_advert_at: 0,
|
});
|
||||||
});
|
self.persist_peers();
|
||||||
self.persist_peers();
|
|
||||||
}
|
|
||||||
std::collections::hash_map::Entry::Occupied(mut e) => {
|
|
||||||
e.get_mut().reachable = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
let Some(data) = ev
|
let Some(data) = ev
|
||||||
@ -1212,10 +1089,8 @@ pub(crate) async fn probe_rnode(path: &str) -> Result<()> {
|
|||||||
// ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART
|
// ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART
|
||||||
// bridge chip) treat a DTR/RTS transition on open as a reset signal, the
|
// bridge chip) treat a DTR/RTS transition on open as a reset signal, the
|
||||||
// same mechanism esptool uses to force bootloader entry. Deassert both
|
// same mechanism esptool uses to force bootloader entry. Deassert both
|
||||||
// before writing the probe. Boards behind a USB-UART bridge (CP2102 on
|
// and let the board settle before writing the probe, or the reboot eats
|
||||||
// the Heltec V3) get the reset pulse from the open() itself, before we
|
// the DETECT_RESP window below.
|
||||||
// can deassert anything — that case is handled by the boot-settle retry
|
|
||||||
// below.
|
|
||||||
let _ = port.set_dtr(false);
|
let _ = port.set_dtr(false);
|
||||||
let _ = port.set_rts(false);
|
let _ = port.set_rts(false);
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
@ -1234,81 +1109,26 @@ pub(crate) async fn probe_rnode(path: &str) -> Result<()> {
|
|||||||
0x00,
|
0x00,
|
||||||
KISS_FEND,
|
KISS_FEND,
|
||||||
];
|
];
|
||||||
// Attempt 1: probe immediately. A board that did NOT reset on open (it
|
|
||||||
// was already up — e.g. a re-probe of a running RNode) answers in well
|
|
||||||
// under a second, so the fast path stays fast.
|
|
||||||
tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe))
|
tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe))
|
||||||
.await
|
.await
|
||||||
.context("RNode probe write timed out")?
|
.context("RNode probe write timed out")?
|
||||||
.context("RNode probe write failed")?;
|
.context("RNode probe write failed")?;
|
||||||
|
|
||||||
let mut buf = [0u8; 256];
|
let mut buf = [0u8; 256];
|
||||||
let mut seen = Vec::new();
|
let mut seen = Vec::new();
|
||||||
if await_detect_resp(&port, &mut buf, &mut seen, PROBE_READ_TIMEOUT).await {
|
let deadline = tokio::time::Instant::now() + PROBE_READ_TIMEOUT;
|
||||||
return Ok(());
|
while tokio::time::Instant::now() < deadline {
|
||||||
}
|
|
||||||
|
|
||||||
// No DETECT_RESP. If the open() power-cycled the board (verified live on
|
|
||||||
// a Heltec V3 RNode behind a CP2102: the ESP32 spends ~2.5-3s in boot
|
|
||||||
// ROM + app init and silently eats anything written meanwhile, so the
|
|
||||||
// first probe lands in the void), wait for its boot chatter to go quiet
|
|
||||||
// and probe once more with a fresh response window.
|
|
||||||
const BOOT_QUIET_WINDOW: Duration = Duration::from_millis(800);
|
|
||||||
const BOOT_SETTLE_MAX: Duration = Duration::from_secs(6);
|
|
||||||
let settle_deadline = tokio::time::Instant::now() + BOOT_SETTLE_MAX;
|
|
||||||
let mut last_data = tokio::time::Instant::now();
|
|
||||||
while tokio::time::Instant::now() < settle_deadline {
|
|
||||||
match tokio::time::timeout(Duration::from_millis(150), port.read(&mut buf)).await {
|
match tokio::time::timeout(Duration::from_millis(150), port.read(&mut buf)).await {
|
||||||
Ok(Ok(n)) if n > 0 => {
|
Ok(Ok(n)) if n > 0 => {
|
||||||
seen.extend_from_slice(&buf[..n]);
|
seen.extend_from_slice(&buf[..n]);
|
||||||
// A late DETECT_RESP to the first write still counts.
|
|
||||||
if contains_detect_resp(&seen) {
|
if contains_detect_resp(&seen) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
last_data = tokio::time::Instant::now();
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if last_data.elapsed() >= BOOT_QUIET_WINDOW {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe))
|
|
||||||
.await
|
|
||||||
.context("RNode probe rewrite timed out")?
|
|
||||||
.context("RNode probe rewrite failed")?;
|
|
||||||
seen.clear();
|
|
||||||
if await_detect_resp(&port, &mut buf, &mut seen, PROBE_READ_TIMEOUT).await {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
anyhow::bail!(
|
|
||||||
"No RNode DETECT_RESP within {:?} (incl. post-boot-settle retry)",
|
|
||||||
PROBE_READ_TIMEOUT
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read from `port` for up to `window`, accumulating into `seen`; true once
|
|
||||||
/// the KISS DETECT_RESP sequence shows up anywhere in the stream.
|
|
||||||
async fn await_detect_resp(
|
|
||||||
port: &serial2_tokio::SerialPort,
|
|
||||||
buf: &mut [u8],
|
|
||||||
seen: &mut Vec<u8>,
|
|
||||||
window: Duration,
|
|
||||||
) -> bool {
|
|
||||||
let deadline = tokio::time::Instant::now() + window;
|
|
||||||
while tokio::time::Instant::now() < deadline {
|
|
||||||
match tokio::time::timeout(Duration::from_millis(150), port.read(buf)).await {
|
|
||||||
Ok(Ok(n)) if n > 0 => {
|
|
||||||
seen.extend_from_slice(&buf[..n]);
|
|
||||||
if contains_detect_resp(seen) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => continue,
|
_ => continue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
anyhow::bail!("No RNode DETECT_RESP within {:?}", PROBE_READ_TIMEOUT)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look for the `[FEND, CMD_DETECT, DETECT_RESP]` sequence anywhere in the
|
/// Look for the `[FEND, CMD_DETECT, DETECT_RESP]` sequence anywhere in the
|
||||||
@ -1318,33 +1138,6 @@ fn contains_detect_resp(buf: &[u8]) -> bool {
|
|||||||
.any(|w| w == [KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP])
|
.any(|w| w == [KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The display name an announce actually asserted, if any.
|
|
||||||
///
|
|
||||||
/// Precedence: the daemon-decoded LXMF display name (`display_name` event
|
|
||||||
/// field), then — legacy peers only — bare-utf8 app_data that wasn't an
|
|
||||||
/// identity blob. The bare-utf8 fallback must actually look like text:
|
|
||||||
/// lossy-decoded msgpack (a new-format announce whose name the daemon failed
|
|
||||||
/// to decode) is full of U+FFFD/control chars and would otherwise become a
|
|
||||||
/// mojibake display name. `None` (e.g. a blob-only legacy announce) means
|
|
||||||
/// "no name asserted" and must NOT stomp a previously-learned name.
|
|
||||||
fn pick_announced_name(
|
|
||||||
explicit_name: Option<String>,
|
|
||||||
app_data_text: Option<String>,
|
|
||||||
is_legacy_blob: bool,
|
|
||||||
) -> Option<String> {
|
|
||||||
explicit_name
|
|
||||||
// A legacy blob-only announce utf8-decodes cleanly, so LXMF's
|
|
||||||
// display_name_from_app_data hands the daemon the ENTIRE `ARCHY:…`
|
|
||||||
// string as a "name" — seen live from a pre-upgrade Framework PT.
|
|
||||||
// An identity blob is never a display name.
|
|
||||||
.filter(|s| !s.starts_with("ARCHY:"))
|
|
||||||
.or_else(|| {
|
|
||||||
app_data_text
|
|
||||||
.filter(|_| !is_legacy_blob)
|
|
||||||
.filter(|s| !s.chars().any(|c| c.is_control() || c == '\u{FFFD}'))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for ReticulumLink {
|
impl Drop for ReticulumLink {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
// Group-wide SIGTERM with a delayed SIGKILL backstop (`terminate_group`).
|
// Group-wide SIGTERM with a delayed SIGKILL backstop (`terminate_group`).
|
||||||
@ -1361,37 +1154,6 @@ impl Drop for ReticulumLink {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn announced_name_precedence() {
|
|
||||||
// Daemon-decoded LXMF name always wins.
|
|
||||||
assert_eq!(
|
|
||||||
pick_announced_name(
|
|
||||||
Some("RNode Shaza".into()),
|
|
||||||
Some("ARCHY:2:aa:bb".into()),
|
|
||||||
true
|
|
||||||
),
|
|
||||||
Some("RNode Shaza".to_string())
|
|
||||||
);
|
|
||||||
// Legacy bare-utf8 name (old daemon, no explicit field).
|
|
||||||
assert_eq!(
|
|
||||||
pick_announced_name(None, Some("zaza".into()), false),
|
|
||||||
Some("zaza".to_string())
|
|
||||||
);
|
|
||||||
// Legacy blob-only announce asserts NO name (must not stomp).
|
|
||||||
assert_eq!(
|
|
||||||
pick_announced_name(None, Some("ARCHY:2:aa:bb".into()), true),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
// Lossy-decoded msgpack must not become a mojibake name.
|
|
||||||
assert_eq!(
|
|
||||||
pick_announced_name(None, Some("\u{FFFD}\u{FFFD}Shaza\u{FFFD}".into()), false),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
assert_eq!(pick_announced_name(None, Some("has\u{1}ctl".into()), false), None);
|
|
||||||
// Nothing at all.
|
|
||||||
assert_eq!(pick_announced_name(None, None, false), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_resp_found_in_kiss_stream() {
|
fn detect_resp_found_in_kiss_stream() {
|
||||||
let stream = [
|
let stream = [
|
||||||
|
|||||||
@ -610,23 +610,12 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
|
|||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for path in detect_serial_devices().await {
|
for path in detect_serial_devices().await {
|
||||||
let usb = usb_info_for_tty(&path).await;
|
let usb = usb_info_for_tty(&path).await;
|
||||||
// Birth time (btime), falling back to inode-change time (ctime) —
|
let plugged_at = tokio::fs::metadata(&path)
|
||||||
// NOT mtime: a tty node's mtime bumps on every open()/write, so with
|
.await
|
||||||
// mtime here each probe/session open minted a "new" plugged_at, the
|
.ok()
|
||||||
// UI's (path, plugged_at) dismissal key never matched again, and the
|
.and_then(|m| m.modified().ok())
|
||||||
// setup modal re-fired forever on a device that never left the port
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
// (observed live on archi-dev-box 2026-07-28). btime/ctime only
|
.map(|d| d.as_secs());
|
||||||
// change when udev (re)creates/chowns the node — i.e. on real plugs.
|
|
||||||
let plugged_at = tokio::fs::metadata(&path).await.ok().and_then(|m| {
|
|
||||||
m.created()
|
|
||||||
.ok()
|
|
||||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
|
||||||
.map(|d| d.as_secs())
|
|
||||||
.or_else(|| {
|
|
||||||
use std::os::unix::fs::MetadataExt;
|
|
||||||
u64::try_from(m.ctime()).ok()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
out.push(DetectedDeviceInfo {
|
out.push(DetectedDeviceInfo {
|
||||||
path,
|
path,
|
||||||
vid: usb.0,
|
vid: usb.0,
|
||||||
|
|||||||
@ -134,7 +134,6 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
|
|||||||
for onion in &unique_onions {
|
for onion in &unique_onions {
|
||||||
let fips_npub = crate::federation::fips_npub_for_onion(data_dir, onion).await;
|
let fips_npub = crate::federation::fips_npub_for_onion(data_dir, onion).await;
|
||||||
match sync_single_peer(
|
match sync_single_peer(
|
||||||
data_dir,
|
|
||||||
fips_npub.as_deref(),
|
fips_npub.as_deref(),
|
||||||
&store,
|
&store,
|
||||||
onion,
|
onion,
|
||||||
@ -174,7 +173,6 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
|
|||||||
/// Sync with a single peer: pull their messages and push ours.
|
/// Sync with a single peer: pull their messages and push ours.
|
||||||
/// Each HTTP call picks FIPS when a npub is known, otherwise Tor.
|
/// Each HTTP call picks FIPS when a npub is known, otherwise Tor.
|
||||||
async fn sync_single_peer(
|
async fn sync_single_peer(
|
||||||
data_dir: &Path,
|
|
||||||
fips_npub: Option<&str>,
|
fips_npub: Option<&str>,
|
||||||
store: &crate::network::dwn_store::DwnStore,
|
store: &crate::network::dwn_store::DwnStore,
|
||||||
onion: &str,
|
onion: &str,
|
||||||
@ -188,8 +186,6 @@ async fn sync_single_peer(
|
|||||||
let (health_resp, _) = PeerRequest::new(fips_npub, onion, "/dwn/health")
|
let (health_resp, _) = PeerRequest::new(fips_npub, onion, "/dwn/health")
|
||||||
.service(crate::settings::transport::PeerService::Federation)
|
.service(crate::settings::transport::PeerService::Federation)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
.fips_timeout(std::time::Duration::from_secs(6))
|
|
||||||
.record_transport(data_dir)
|
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
.context("Peer DWN unreachable")?;
|
.context("Peer DWN unreachable")?;
|
||||||
@ -215,8 +211,6 @@ async fn sync_single_peer(
|
|||||||
let (pull_res, _) = PeerRequest::new(fips_npub, onion, "/dwn")
|
let (pull_res, _) = PeerRequest::new(fips_npub, onion, "/dwn")
|
||||||
.service(crate::settings::transport::PeerService::Federation)
|
.service(crate::settings::transport::PeerService::Federation)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
.fips_timeout(std::time::Duration::from_secs(6))
|
|
||||||
.record_transport(data_dir)
|
|
||||||
.send_json(&pull_body)
|
.send_json(&pull_body)
|
||||||
.await
|
.await
|
||||||
.context("Failed to query peer DWN")?;
|
.context("Failed to query peer DWN")?;
|
||||||
@ -275,8 +269,6 @@ async fn sync_single_peer(
|
|||||||
match PeerRequest::new(fips_npub, onion, "/dwn")
|
match PeerRequest::new(fips_npub, onion, "/dwn")
|
||||||
.service(crate::settings::transport::PeerService::Federation)
|
.service(crate::settings::transport::PeerService::Federation)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
.fips_timeout(std::time::Duration::from_secs(6))
|
|
||||||
.record_transport(data_dir)
|
|
||||||
.send_json(&push_body)
|
.send_json(&push_body)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@ -342,9 +342,6 @@ pub async fn send_to_peer(
|
|||||||
signing_key: Option<&ed25519_dalek::SigningKey>,
|
signing_key: Option<&ed25519_dalek::SigningKey>,
|
||||||
recipient_pubkey: Option<&str>,
|
recipient_pubkey: Option<&str>,
|
||||||
from_name: Option<&str>,
|
from_name: Option<&str>,
|
||||||
// Federation data dir for last-transport recording; None skips recording
|
|
||||||
// (callers without a data dir in scope).
|
|
||||||
record_data_dir: Option<&std::path::Path>,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
validate_onion(onion)?;
|
validate_onion(onion)?;
|
||||||
|
|
||||||
@ -373,15 +370,10 @@ pub async fn send_to_peer(
|
|||||||
body["from_name"] = serde_json::Value::String(name.to_string());
|
body["from_name"] = serde_json::Value::String(name.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut req =
|
let (resp, transport) =
|
||||||
crate::fips::dial::PeerRequest::new(fips_npub, onion, "/archipelago/node-message")
|
crate::fips::dial::PeerRequest::new(fips_npub, onion, "/archipelago/node-message")
|
||||||
.service(crate::settings::transport::PeerService::Messaging)
|
.service(crate::settings::transport::PeerService::Messaging)
|
||||||
.timeout(std::time::Duration::from_secs(60))
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
.fips_timeout(std::time::Duration::from_secs(8));
|
|
||||||
if let Some(dir) = record_data_dir {
|
|
||||||
req = req.record_transport(dir);
|
|
||||||
}
|
|
||||||
let (resp, transport) = req
|
|
||||||
.send_json(&body)
|
.send_json(&body)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@ -418,7 +410,6 @@ pub async fn check_peer_reachable(onion: &str, fips_npub: Option<&str>) -> Resul
|
|||||||
// circuit that hasn't answered /health in 12s is "offline" for UI
|
// circuit that hasn't answered /health in 12s is "offline" for UI
|
||||||
// purposes; the old 30s made the Connected Nodes probes crawl.
|
// purposes; the old 30s made the Connected Nodes probes crawl.
|
||||||
.timeout(std::time::Duration::from_secs(12))
|
.timeout(std::time::Duration::from_secs(12))
|
||||||
.fips_timeout(std::time::Duration::from_secs(4))
|
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@ -294,13 +294,8 @@ impl Server {
|
|||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Auto-enable mesh if a radio is detected and no config exists
|
// Auto-enable mesh if a radio is detected and no config exists yet
|
||||||
// yet. Only on a genuinely missing config: an existing file
|
if !mesh_config.enabled {
|
||||||
// with enabled=false is an explicit operator decision (e.g.
|
|
||||||
// via mesh.configure) and force-re-enabling it on every boot
|
|
||||||
// made "disable mesh" impossible on any node with a radio
|
|
||||||
// plugged in.
|
|
||||||
if !mesh_config.enabled && !crate::mesh::config_file_exists(&data_dir) {
|
|
||||||
let devices = crate::mesh::detect_devices().await;
|
let devices = crate::mesh::detect_devices().await;
|
||||||
if !devices.is_empty() {
|
if !devices.is_empty() {
|
||||||
info!("📡 Auto-detected mesh radio: {:?} — enabling mesh", devices);
|
info!("📡 Auto-detected mesh radio: {:?} — enabling mesh", devices);
|
||||||
@ -438,18 +433,8 @@ impl Server {
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
||||||
// LAN transport (mDNS discovery). Advertise our FIPS npub in
|
// LAN transport (mDNS discovery)
|
||||||
// the TXT record so co-located peers can form a direct FIPS
|
let mut lan = crate::transport::lan::LanTransport::new(&did, &pubkey_hex, 5678);
|
||||||
// link (see `lan_fips_anchors`).
|
|
||||||
let local_fips_npub = crate::identity::fips_npub(&data_dir.join("identity"))
|
|
||||||
.await
|
|
||||||
.unwrap_or(None);
|
|
||||||
let mut lan = crate::transport::lan::LanTransport::new(
|
|
||||||
&did,
|
|
||||||
&pubkey_hex,
|
|
||||||
5678,
|
|
||||||
local_fips_npub,
|
|
||||||
);
|
|
||||||
match lan.start(registry.clone()) {
|
match lan.start(registry.clone()) {
|
||||||
Ok(()) => info!("📡 LAN transport (mDNS) started"),
|
Ok(()) => info!("📡 LAN transport (mDNS) started"),
|
||||||
Err(e) => debug!("LAN transport init (non-fatal): {}", e),
|
Err(e) => debug!("LAN transport init (non-fatal): {}", e),
|
||||||
@ -768,25 +753,12 @@ impl Server {
|
|||||||
// (often flaky) global anchor's spanning tree to route to each
|
// (often flaky) global anchor's spanning tree to route to each
|
||||||
// other. For every peer the registry knows both a LAN address
|
// other. For every peer the registry knows both a LAN address
|
||||||
// AND a FIPS npub for, dial it on its FIPS UDP transport port
|
// AND a FIPS npub for, dial it on its FIPS UDP transport port
|
||||||
// at its LAN IP. This is FIPS's own transport over the
|
// (8668) at its LAN IP. This is FIPS's own transport over the
|
||||||
// LAN — NOT Tailscale, NOT the HTTP/LAN messaging port. Pure
|
// LAN — NOT Tailscale, NOT the HTTP/LAN messaging port. Pure
|
||||||
// FIPS. `fipsctl connect` is idempotent, so re-applying every
|
// FIPS. `fipsctl connect` is idempotent, so re-applying every
|
||||||
// tick just keeps the direct link warm; unknown/remote peers
|
// tick just keeps the direct link warm; unknown/remote peers
|
||||||
// (no LAN address) are left to the anchor as before.
|
// (no LAN address) are left to the anchor as before.
|
||||||
if let Some(reg) = fips_peer_registry.as_ref() {
|
if let Some(reg) = fips_peer_registry.as_ref() {
|
||||||
// Hydrate FIPS npubs into the registry from federation
|
|
||||||
// storage (did-keyed). Peers discovered before the mDNS
|
|
||||||
// TXT `fips` key existed — or running builds that don't
|
|
||||||
// advertise it yet — would otherwise never satisfy the
|
|
||||||
// `fips_npub` requirement in lan_fips_anchors(), leaving
|
|
||||||
// direct LAN peering a no-op.
|
|
||||||
if let Ok(nodes) = crate::federation::load_nodes(&data_dir).await {
|
|
||||||
for n in &nodes {
|
|
||||||
if let Some(npub) = n.fips_npub.as_deref() {
|
|
||||||
reg.set_fips_npub(&n.did, npub).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let direct = crate::fips::anchors::lan_fips_anchors(®.all_peers().await);
|
let direct = crate::fips::anchors::lan_fips_anchors(®.all_peers().await);
|
||||||
if !direct.is_empty() {
|
if !direct.is_empty() {
|
||||||
let _ = crate::fips::anchors::apply(&direct).await;
|
let _ = crate::fips::anchors::apply(&direct).await;
|
||||||
@ -919,7 +891,7 @@ impl Server {
|
|||||||
// Post-onboarding auto-activation for archipelago-fips. Runs once
|
// Post-onboarding auto-activation for archipelago-fips. Runs once
|
||||||
// at startup: if fips_key is on disk, install /etc/fips/fips.yaml
|
// at startup: if fips_key is on disk, install /etc/fips/fips.yaml
|
||||||
// (schema-refreshed) and start the service. This removes the
|
// (schema-refreshed) and start the service. This removes the
|
||||||
// need for a user-facing manual Start button — the node comes up
|
// need for a user-facing "Activate" button — the node comes up
|
||||||
// with FIPS running whenever the seed has been onboarded. Also
|
// with FIPS running whenever the seed has been onboarded. Also
|
||||||
// self-heals legacy raw-byte fips.key files (load_fips_keys
|
// self-heals legacy raw-byte fips.key files (load_fips_keys
|
||||||
// rewrites them as bech32 nsec the first time they're read).
|
// rewrites them as bech32 nsec the first time they're read).
|
||||||
@ -968,16 +940,14 @@ impl Server {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let unit = crate::fips::service::activation_unit().await;
|
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
|
||||||
if let Err(e) = crate::fips::service::activate(unit).await {
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"FIPS activate failed on startup via {}: {} — user can retry via fips.install RPC",
|
"archipelago-fips activate failed on startup: {} — user can retry via fips.install RPC",
|
||||||
unit,
|
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tracing::info!("FIPS auto-activated on startup via {}", unit);
|
tracing::info!("archipelago-fips auto-activated on startup");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1030,8 +1000,10 @@ impl Server {
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let v6_task = if let Some(port) = v4_any_port {
|
let v6_task = if let Some(port) = v4_any_port {
|
||||||
let v6_addr =
|
let v6_addr = SocketAddr::new(
|
||||||
SocketAddr::new(std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), port);
|
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
|
||||||
|
port,
|
||||||
|
);
|
||||||
match bind_v6_only(v6_addr) {
|
match bind_v6_only(v6_addr) {
|
||||||
Ok(listener) => {
|
Ok(listener) => {
|
||||||
info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr);
|
info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr);
|
||||||
@ -1045,10 +1017,7 @@ impl Server {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!("IPv6 web listener bind {} failed: {} — UI stays v4-only", v6_addr, e);
|
||||||
"IPv6 web listener bind {} failed: {} — UI stays v4-only",
|
|
||||||
v6_addr, e
|
|
||||||
);
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1062,9 +1031,9 @@ impl Server {
|
|||||||
// IPv4 only for most apps, so a phone dialing [ULA]:8123 got
|
// IPv4 only for most apps, so a phone dialing [ULA]:8123 got
|
||||||
// nothing even with the firewall open (HA/FileBrowser/Gitea/
|
// nothing even with the firewall open (HA/FileBrowser/Gitea/
|
||||||
// Portainer/Pine all v4-only on 2026-07-26; a few bind [::]
|
// Portainer/Pine all v4-only on 2026-07-26; a few bind [::]
|
||||||
// themselves). Bridge each catalog launch port on the fips0 ULA
|
// themselves). Bridge each catalog launch port to its v4 loopback
|
||||||
// only. Binding wildcard [::]:port reserves the same host ports
|
// listener with a V6ONLY relay — self-selecting: where the app
|
||||||
// Podman needs and can restart-loop apps that publish those ports.
|
// already answers on v6 the bind fails and the app wins.
|
||||||
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
|
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
|
||||||
|
|
||||||
let peer_task = tokio::spawn(peer_late_bind_loop(
|
let peer_task = tokio::spawn(peer_late_bind_loop(
|
||||||
@ -1118,14 +1087,10 @@ fn bind_v6_only(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
|
|||||||
tokio::net::TcpListener::from_std(socket.into())
|
tokio::net::TcpListener::from_std(socket.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fips_app_relay_addr(ip: std::net::Ipv6Addr, port: u16) -> SocketAddr {
|
|
||||||
SocketAddr::new(std::net::IpAddr::V6(ip), port)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// IPv6→IPv4 relay for catalog app launch ports (see the spawn site for
|
/// IPv6→IPv4 relay for catalog app launch ports (see the spawn site for
|
||||||
/// why). Rescans every 60s so ports of freshly installed apps get bridged
|
/// why). Rescans every 60s so ports of freshly installed apps get bridged
|
||||||
/// without a daemon restart. Each relay binds to the fips0 ULA only and
|
/// without a daemon restart. Each relay is a V6ONLY listener forwarding
|
||||||
/// forwards raw TCP to the same port on IPv4 loopback.
|
/// raw TCP to the same port on IPv4 loopback.
|
||||||
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
|
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
let mut bridged: HashSet<u16> = HashSet::new();
|
let mut bridged: HashSet<u16> = HashSet::new();
|
||||||
@ -1134,7 +1099,6 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
|
|||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = interval.tick() => {
|
_ = interval.tick() => {
|
||||||
let Some(fips_ip) = crate::fips::iface::fips0_ula() else { continue };
|
|
||||||
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
|
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
|
||||||
if bridged.contains(&port) {
|
if bridged.contains(&port) {
|
||||||
continue;
|
continue;
|
||||||
@ -1159,12 +1123,15 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
|
|||||||
if !v4_up {
|
if !v4_up {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let addr = fips_app_relay_addr(fips_ip, port);
|
let addr = SocketAddr::new(
|
||||||
// EADDRINUSE = fipsd or another process already answers
|
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
|
||||||
// on this mesh address/port, so stay out of the way.
|
port,
|
||||||
|
);
|
||||||
|
// EADDRINUSE = the app itself already answers on v6 —
|
||||||
|
// exactly when we must stay out of the way.
|
||||||
let Ok(listener) = bind_v6_only(addr) else { continue };
|
let Ok(listener) = bind_v6_only(addr) else { continue };
|
||||||
bridged.insert(port);
|
bridged.insert(port);
|
||||||
debug!("v6 relay bridging [{fips_ip}]:{port} -> 127.0.0.1:{port}");
|
debug!("v6 relay bridging [::]:{port} -> 127.0.0.1:{port}");
|
||||||
let mut rx = shutdown_rx.clone();
|
let mut rx = shutdown_rx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
@ -1222,36 +1189,18 @@ async fn peer_late_bind_loop(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
info!("FIPS peer listener bound {}", addr);
|
info!("FIPS peer listener bound {}", addr);
|
||||||
// Serve until shutdown, a persistent accept failure, or a
|
// Once bound, serve until shutdown fires. accept_loop
|
||||||
// fips0 ULA change. The listener must be REBINDABLE: a
|
// returns on shutdown, which also ends this outer loop.
|
||||||
// daemon re-key tears fips0 down and brings it back with a
|
accept_loop(
|
||||||
// (possibly different) ULA, and the old one-shot bind left
|
handler,
|
||||||
// the node inbound-dead over FIPS until process restart.
|
listener,
|
||||||
tokio::select! {
|
active_connections,
|
||||||
_ = accept_loop(
|
true, // peer listener: apply path filter
|
||||||
handler.clone(),
|
shutdown_rx,
|
||||||
listener,
|
addr,
|
||||||
active_connections.clone(),
|
)
|
||||||
true, // peer listener: apply path filter
|
.await;
|
||||||
shutdown_rx.clone(),
|
return;
|
||||||
addr,
|
|
||||||
) => {
|
|
||||||
if *shutdown_rx.borrow() { return; }
|
|
||||||
warn!("FIPS peer accept loop ended — rebinding");
|
|
||||||
}
|
|
||||||
_ = async {
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
|
||||||
if crate::fips::iface::fips0_ula() != Some(ip) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} => {
|
|
||||||
info!("fips0 ULA changed — rebinding FIPS peer listener");
|
|
||||||
// Dropping the select arm cancels accept_loop and
|
|
||||||
// frees the socket; the outer loop rebinds fresh.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ = shutdown_rx.changed() => {
|
_ = shutdown_rx.changed() => {
|
||||||
if *shutdown_rx.borrow() { return; }
|
if *shutdown_rx.borrow() { return; }
|
||||||
@ -1287,12 +1236,6 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|
|||||||
)
|
)
|
||||||
// Prefix-matched content endpoints (peer file browse + fetch)
|
// Prefix-matched content endpoints (peer file browse + fetch)
|
||||||
|| path.starts_with("/content/")
|
|| path.starts_with("/content/")
|
||||||
// Mesh file sharing — blob fetch by CID, signature-gated in the
|
|
||||||
// handler. Absent from this list it 404'd over FIPS and the feature
|
|
||||||
// was 100% Tor by construction.
|
|
||||||
|| path.starts_with("/blob/")
|
|
||||||
// DWN sync — /dwn/health is step 1 of every sync; same story.
|
|
||||||
|| path.starts_with("/dwn/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn accept_loop(
|
async fn accept_loop(
|
||||||
@ -1303,70 +1246,22 @@ async fn accept_loop(
|
|||||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
local_addr: SocketAddr,
|
local_addr: SocketAddr,
|
||||||
) {
|
) {
|
||||||
// Consecutive accept-error tracking: a fips0 teardown/re-key leaves the
|
|
||||||
// peer listener's socket permanently broken — `continue`-ing forever
|
|
||||||
// made the node inbound-dead over FIPS until process restart. After a
|
|
||||||
// burst of consecutive errors the peer accept loop returns so its
|
|
||||||
// caller (peer_late_bind_loop) can rebind on the current ULA.
|
|
||||||
let mut consecutive_errors: u32 = 0;
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
result = listener.accept() => {
|
result = listener.accept() => {
|
||||||
let (stream, peer_addr) = match result {
|
let (stream, peer_addr) = match result {
|
||||||
Ok(c) => { consecutive_errors = 0; c }
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("{} accept error: {}", local_addr, e);
|
error!("{} accept error: {}", local_addr, e);
|
||||||
consecutive_errors += 1;
|
|
||||||
if peer_only && consecutive_errors >= 10 {
|
|
||||||
warn!("{} accept failing persistently — returning for rebind", local_addr);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Don't hot-loop on a dead socket.
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let handler = handler.clone();
|
let handler = handler.clone();
|
||||||
// NEVER park the accept loop on the connection budget.
|
let permit = active_connections.clone().acquire_owned().await;
|
||||||
// `acquire_owned().await` here froze accept() entirely when
|
|
||||||
// permits drained — and permits drained because half-open
|
|
||||||
// clients and hung upstreams held them forever (the .228
|
|
||||||
// session-flapping / CLOSE-WAIT `inode: 0` signature). Shed
|
|
||||||
// load instead: accept, answer 503, close.
|
|
||||||
let permit = match active_connections.clone().try_acquire_owned() {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => {
|
|
||||||
warn!(
|
|
||||||
"{} connection budget exhausted — shedding {}",
|
|
||||||
local_addr, peer_addr
|
|
||||||
);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
let mut stream = stream;
|
|
||||||
let _ = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(5),
|
|
||||||
stream.write_all(
|
|
||||||
b"HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let _ = stream.shutdown().await;
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
// Set when a request carries an Upgrade header (websocket):
|
|
||||||
// upgraded connections are legitimately long-lived and are
|
|
||||||
// exempt from the non-upgraded connection deadline below.
|
|
||||||
let upgraded = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
||||||
let upgraded_flag = upgraded.clone();
|
|
||||||
let service = service_fn(move |mut req: hyper::Request<hyper::Body>| {
|
let service = service_fn(move |mut req: hyper::Request<hyper::Body>| {
|
||||||
let handler = handler.clone();
|
let handler = handler.clone();
|
||||||
if req.headers().contains_key(hyper::header::UPGRADE) {
|
|
||||||
upgraded_flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
async move {
|
async move {
|
||||||
// Record the TCP peer so rate limiting only trusts
|
// Record the TCP peer so rate limiting only trusts
|
||||||
// forwarded headers on loopback (nginx) connections.
|
// forwarded headers on loopback (nginx) connections.
|
||||||
@ -1385,48 +1280,13 @@ async fn accept_loop(
|
|||||||
.map_err(|e| std::io::Error::other(format!("{}", e)))
|
.map_err(|e| std::io::Error::other(format!("{}", e)))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// header_read_timeout: a client that connects and never
|
if let Err(e) = Http::new()
|
||||||
// sends a request (slowloris / half-open) is dropped
|
|
||||||
// instead of holding a permit until the heat death of the
|
|
||||||
// node. Long RPCs are safe — the clock only covers header
|
|
||||||
// read.
|
|
||||||
let conn = Http::new()
|
|
||||||
.http1_keep_alive(false)
|
.http1_keep_alive(false)
|
||||||
.http1_header_read_timeout(std::time::Duration::from_secs(30))
|
|
||||||
.serve_connection(stream, service)
|
.serve_connection(stream, service)
|
||||||
.with_upgrades();
|
.with_upgrades()
|
||||||
tokio::pin!(conn);
|
.await
|
||||||
// Deadline watchdog for NON-upgraded connections. With
|
{
|
||||||
// keep-alive off a plain connection serves one exchange;
|
error!("Error serving connection from {}: {}", peer_addr, e);
|
||||||
// 15 min bounds even the slowest legitimate RPC/stream
|
|
||||||
// while guaranteeing a hung upstream can't hold a permit
|
|
||||||
// forever. Upgraded (websocket) connections are exempt.
|
|
||||||
const NON_UPGRADED_DEADLINE: std::time::Duration =
|
|
||||||
std::time::Duration::from_secs(900);
|
|
||||||
let started = std::time::Instant::now();
|
|
||||||
let watchdog = async {
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
|
||||||
if !upgraded.load(std::sync::atomic::Ordering::Relaxed)
|
|
||||||
&& started.elapsed() >= NON_UPGRADED_DEADLINE
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tokio::select! {
|
|
||||||
r = &mut conn => {
|
|
||||||
if let Err(e) = r {
|
|
||||||
error!("Error serving connection from {}: {}", peer_addr, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = watchdog => {
|
|
||||||
warn!(
|
|
||||||
"connection from {} exceeded {}s without completing or upgrading — dropping",
|
|
||||||
peer_addr,
|
|
||||||
NON_UPGRADED_DEADLINE.as_secs()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -2079,25 +1939,10 @@ mod merge_tests {
|
|||||||
);
|
);
|
||||||
assert!(is_peer_allowed_path("/rpc/v1"));
|
assert!(is_peer_allowed_path("/rpc/v1"));
|
||||||
assert!(is_peer_allowed_path("/health"));
|
assert!(is_peer_allowed_path("/health"));
|
||||||
// Mesh blob fetch + DWN sync — both were missing from the allowlist,
|
|
||||||
// which made them deterministically 404 over FIPS and therefore
|
|
||||||
// 100% Tor by construction.
|
|
||||||
assert!(is_peer_allowed_path("/blob/abc123"), "blob fetch by CID");
|
|
||||||
assert!(is_peer_allowed_path("/dwn/health"), "DWN sync step 1");
|
|
||||||
// Not on the allow-list → rejected (no broad surface over the mesh).
|
// Not on the allow-list → rejected (no broad surface over the mesh).
|
||||||
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
|
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
|
||||||
assert!(!is_peer_allowed_path("/"));
|
assert!(!is_peer_allowed_path("/"));
|
||||||
assert!(!is_peer_allowed_path("/rpc/v2"));
|
assert!(!is_peer_allowed_path("/rpc/v2"));
|
||||||
assert!(!is_peer_allowed_path("/blobber"), "must not prefix-leak");
|
|
||||||
assert!(!is_peer_allowed_path("/dwnx"), "must not prefix-leak");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn app_relay_binds_to_fips_ula_not_wildcard() {
|
|
||||||
let ula = "fd12:3456:789a::1".parse().unwrap();
|
|
||||||
let addr = fips_app_relay_addr(ula, 8083);
|
|
||||||
assert_eq!(addr.ip(), std::net::IpAddr::V6(ula));
|
|
||||||
assert_eq!(addr.port(), 8083);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -24,27 +24,17 @@ pub struct LanTransport {
|
|||||||
our_did: String,
|
our_did: String,
|
||||||
our_pubkey_hex: String,
|
our_pubkey_hex: String,
|
||||||
our_port: u16,
|
our_port: u16,
|
||||||
/// This node's FIPS npub, advertised in the mDNS TXT record so
|
|
||||||
/// co-located peers can form a direct FIPS link (`lan_fips_anchors`)
|
|
||||||
/// without waiting for federation storage to sync.
|
|
||||||
our_fips_npub: Option<String>,
|
|
||||||
daemon: Option<ServiceDaemon>,
|
daemon: Option<ServiceDaemon>,
|
||||||
available: AtomicBool,
|
available: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LanTransport {
|
impl LanTransport {
|
||||||
/// Create a new LAN transport. Does not start discovery yet.
|
/// Create a new LAN transport. Does not start discovery yet.
|
||||||
pub fn new(
|
pub fn new(our_did: &str, our_pubkey_hex: &str, port: u16) -> Self {
|
||||||
our_did: &str,
|
|
||||||
our_pubkey_hex: &str,
|
|
||||||
port: u16,
|
|
||||||
our_fips_npub: Option<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
our_did: our_did.to_string(),
|
our_did: our_did.to_string(),
|
||||||
our_pubkey_hex: our_pubkey_hex.to_string(),
|
our_pubkey_hex: our_pubkey_hex.to_string(),
|
||||||
our_port: port,
|
our_port: port,
|
||||||
our_fips_npub,
|
|
||||||
daemon: None,
|
daemon: None,
|
||||||
available: AtomicBool::new(false),
|
available: AtomicBool::new(false),
|
||||||
}
|
}
|
||||||
@ -57,14 +47,11 @@ impl LanTransport {
|
|||||||
|
|
||||||
// Advertise our service
|
// Advertise our service
|
||||||
let hostname = format!("archy-{}.local.", &self.our_pubkey_hex[..8]);
|
let hostname = format!("archy-{}.local.", &self.our_pubkey_hex[..8]);
|
||||||
let mut properties = vec![
|
let properties = vec![
|
||||||
("did".to_string(), self.our_did.clone()),
|
("did".to_string(), self.our_did.clone()),
|
||||||
("pubkey".to_string(), self.our_pubkey_hex.clone()),
|
("pubkey".to_string(), self.our_pubkey_hex.clone()),
|
||||||
("version".to_string(), "0.1.0".to_string()),
|
("version".to_string(), "0.1.0".to_string()),
|
||||||
];
|
];
|
||||||
if let Some(npub) = &self.our_fips_npub {
|
|
||||||
properties.push(("fips".to_string(), npub.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let service_info = ServiceInfo::new(
|
let service_info = ServiceInfo::new(
|
||||||
SERVICE_TYPE,
|
SERVICE_TYPE,
|
||||||
@ -106,11 +93,6 @@ impl LanTransport {
|
|||||||
.map(|v| v.val_str().to_string());
|
.map(|v| v.val_str().to_string());
|
||||||
let addresses = info.get_addresses();
|
let addresses = info.get_addresses();
|
||||||
|
|
||||||
let fips_npub = info
|
|
||||||
.get_properties()
|
|
||||||
.get("fips")
|
|
||||||
.map(|v| v.val_str().to_string());
|
|
||||||
|
|
||||||
if let (Some(did), Some(pubkey)) = (did, pubkey) {
|
if let (Some(did), Some(pubkey)) = (did, pubkey) {
|
||||||
if let Some(scoped_ip) = addresses.iter().next() {
|
if let Some(scoped_ip) = addresses.iter().next() {
|
||||||
let ip: std::net::IpAddr = match scoped_ip.to_string().parse() {
|
let ip: std::net::IpAddr = match scoped_ip.to_string().parse() {
|
||||||
@ -124,9 +106,6 @@ impl LanTransport {
|
|||||||
.await;
|
.await;
|
||||||
registry_clone.set_lan_address(&did, socket_addr).await;
|
registry_clone.set_lan_address(&did, socket_addr).await;
|
||||||
registry_clone.set_name(&did, info.get_fullname()).await;
|
registry_clone.set_name(&did, info.get_fullname()).await;
|
||||||
if let Some(npub) = fips_npub.as_deref() {
|
|
||||||
registry_clone.set_fips_npub(&did, npub).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,7 +106,7 @@ pub enum PeerSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Unified peer record with per-transport capabilities.
|
/// Unified peer record with per-transport capabilities.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PeerRecord {
|
pub struct PeerRecord {
|
||||||
pub did: String,
|
pub did: String,
|
||||||
pub pubkey_hex: String,
|
pub pubkey_hex: String,
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
# Demo Content Provenance
|
|
||||||
|
|
||||||
All media in `demo/content/` and `demo/peer-media/` — music tracks, photos,
|
|
||||||
book covers, posters, and documents — are original works created and owned by
|
|
||||||
the Archipelago project author (Dorian), included here as demo content and
|
|
||||||
released with the project under the repository MIT license.
|
|
||||||
|
|
||||||
None of this content is sourced from third-party stock libraries or
|
|
||||||
commercial catalogs.
|
|
||||||
@ -1,300 +0,0 @@
|
|||||||
# FIPS near-100% uptime + optimistic UI state — implementation plan
|
|
||||||
|
|
||||||
**Date:** 2026-07-27. **Status:** researched + root-caused live on the fleet; ready to
|
|
||||||
implement for the next release. Two workstreams: (A) make node↔node FIPS transport
|
|
||||||
succeed whenever a FIPS path physically exists, (B) stop the UI reloading everything
|
|
||||||
on every navigation (optimistic/cached cards, stale-while-revalidate) while keeping
|
|
||||||
data fresh.
|
|
||||||
|
|
||||||
**Honesty note on "100%":** if a node's network blackholes every anchor (the .116
|
|
||||||
WiFi case, `docs/HANDOFF-2026-07-20-fips-peer-files.md:117-133`), Tor fallback is
|
|
||||||
*correct*. The achievable target is: **FIPS wins whenever a FIPS path exists, and
|
|
||||||
fallback frequency is measured in-product so regressions are visible.** Today several
|
|
||||||
paths are 0% FIPS *by construction* regardless of network health — that's the bug.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part A — why Cloud/FIPS "commonly falls back to Tor": ranked root causes
|
|
||||||
|
|
||||||
All verified live on 2026-07-27 (.116 local, .198, .228, Framework PT, x250s) plus a
|
|
||||||
full code audit of `core/archipelago/src/{fips,transport,federation,server.rs}`.
|
|
||||||
|
|
||||||
### RC0 — 🔥 The hardening firewall drops the peer-API port on every hardened node (PROVEN)
|
|
||||||
|
|
||||||
The fips0 default-deny baseline (`/etc/fips/fips.nft`) is opened by archipelago's
|
|
||||||
drop-in `80-web-ui.nft` (`fips/config.rs:236-255`) for **80 + 8443 + app ports only**.
|
|
||||||
The peer-API listener — which carries *all* federation sync, cloud browse/download,
|
|
||||||
mesh envelopes, DWN, invoices — is **`PEER_PORT = 5679`** (`fips/dial.rs:35`).
|
|
||||||
**5679 is not in the allowlist.** The drop-in's own comment claims "web UI + peer
|
|
||||||
API" but the peer API port was never added.
|
|
||||||
|
|
||||||
Live proof (2026-07-27):
|
|
||||||
- .116 nft chain: 5,965 dropped packets; .198: **28,670 dropped packets** — that's
|
|
||||||
peers' FIPS dials dying at the firewall.
|
|
||||||
- .198 → .116 `GET :5679/health`: **timeout (6s)** before; **HTTP 200 in 0.35s**
|
|
||||||
after `nft insert rule inet fips inbound iifname fips0 tcp dport 5679 accept`.
|
|
||||||
Same result in reverse direction (200 in 0.64s).
|
|
||||||
- Explains the exact fleet split in `federation/nodes.json`: hardened-baseline nodes
|
|
||||||
(Framework PT, .198, .228, x250-dev, x250-mad2) = `last_transport: tor`;
|
|
||||||
non-hardened nodes (Austin Sapien, X250-Beta, X250-PA) answer :5679 (404 from the
|
|
||||||
path allowlist = listener reachable) = `last_transport: fips`.
|
|
||||||
- Every dial to a hardened peer pays the 8s FIPS connect timeout
|
|
||||||
(`dial.rs:114`) ×2 (retry, `dial.rs:128-140`) → then Tor. That's the "Cloud takes
|
|
||||||
forever / shows Tor" experience.
|
|
||||||
|
|
||||||
**Fix (one line + reload):** add `tcp dport 5679 accept` to the drop-in in
|
|
||||||
`fips/config.rs` (use a constant shared with `dial.rs::PEER_PORT`, not a literal).
|
|
||||||
The drop-in reinstalls on every daemon config install, so it heals fleet-wide on OTA.
|
|
||||||
⚠️ Transient manual rules were inserted on .116 and .198 during diagnosis (2026-07-27)
|
|
||||||
— they vanish on the next `nft -f /etc/fips/fips.nft` reload or reboot; the code fix
|
|
||||||
makes them permanent.
|
|
||||||
|
|
||||||
### RC1 — .228 (Shorty's) runs fips 0.3.0-dev; the 0.4.1 fleet can't reach it
|
|
||||||
|
|
||||||
.228's daemon: `0.3.0-dev (rev 34e00b9f6e)`, both anchor links "connected", but its
|
|
||||||
ULA is 100% unreachable from 0.4.1 nodes (ping loss 100%). FIPS wire format is not
|
|
||||||
stable across revs (`docs/HANDOFF-2026-07-23-companion-apk-deploy.md:78`). Everything
|
|
||||||
to/from .228 rides Tor no matter what else we fix.
|
|
||||||
|
|
||||||
**Fix:** fleet fips-version audit + upgrade to v0.4.1 everywhere (in-product updater
|
|
||||||
exists: `fips/update.rs`; .deb path per `reference_vps2_fips_anchor`). Add a version
|
|
||||||
check to `fips.status` and surface a "peer daemon outdated" warning.
|
|
||||||
|
|
||||||
### RC2 — Direct LAN/endpoint peering is dead code + wrong port + stale seed anchors
|
|
||||||
|
|
||||||
Without direct links, all peer traffic hairpins through the vps2 anchor spanning
|
|
||||||
tree (observed: .116→.198 cold RTT 1.5–3.5s on the same LAN; also the wedged-anchor
|
|
||||||
latency-rot incident, `HANDOFF-2026-07-23:141-160`).
|
|
||||||
|
|
||||||
- **G1 — `lan_fips_anchors()` has never run.** It needs `PeerRecord.fips_npub`, but
|
|
||||||
`PeerRegistry::set_fips_npub` (`transport/mod.rs:302`) has **zero callers** — mDNS
|
|
||||||
TXT records only carry `did`/`pubkey`/`version` (`transport/lan.rs:50-54`). So the
|
|
||||||
"co-located peers form a direct link" feature (`anchors.rs:294-305`,
|
|
||||||
`server.rs:761-766`) is a fleet-wide no-op.
|
|
||||||
- **G2 — wrong UDP port.** `anchors.rs:293` dials `8668`, but the generated
|
|
||||||
fips.yaml binds UDP **2121** (`fips/config.rs:187`, `fips/mod.rs:130`). Even if G1
|
|
||||||
ran, it would dial a dead port. `.116`'s live `seed-anchors.json` still carries
|
|
||||||
`.198@192.168.1.198:8668` — **stale IP (LAN renumbered to 192.168.63.x) AND dead
|
|
||||||
port**; both manual entries are useless today.
|
|
||||||
- No Tailscale/alternate endpoint fallback when LAN is unreachable (the .116↔.198
|
|
||||||
fix of 2026-07-20 was hand-applied per-node config, never productized).
|
|
||||||
|
|
||||||
**Fix:** (a) `FIPS_UDP_PORT` → `crate::fips::PUBLISHED_UDP_PORT` + drift-guard test;
|
|
||||||
(b) hydrate `fips_npub` into the registry from federation storage (did-keyed join) so
|
|
||||||
`lan_fips_anchors` goes live with no wire change; (c) advertise the npub in the mDNS
|
|
||||||
TXT + `set_fips_npub` on resolve as the proper fix; (d) teach the LAN-anchor tick to
|
|
||||||
also try a peer's Tailscale/last-known-good endpoint when LAN fails (reviewed change
|
|
||||||
— this area got handoffs wrong twice, per memory).
|
|
||||||
|
|
||||||
### RC3 — No fast-fail on the hottest call sites; retry silently doubles every budget
|
|
||||||
|
|
||||||
- `content.browse-peer` — **the Cloud page** — has NO `fips_timeout`
|
|
||||||
(`api/rpc/content.rs:363-366`): a cold FIPS path burns up to ~16.6s (8s connect +
|
|
||||||
600ms + 8s retry) before Tor even starts, against a UI deadline of 30s
|
|
||||||
(`Cloud.vue:720`) — and the frontend then retries ×3. Users see errors, not
|
|
||||||
fallback. 12 call sites total lack `fips_timeout` (browse/download/preview-peer,
|
|
||||||
`/blob`, DWN, node_message, rotation notifies).
|
|
||||||
- `dial.rs:128-140` runs 2 full-budget attempts, so `fips_timeout(6s)` really means
|
|
||||||
~12.6s everywhere.
|
|
||||||
|
|
||||||
**Fix:** wrap `send_with_retry` in a single `tokio::time::timeout(fips_attempt_timeout())`
|
|
||||||
(call sites `dial.rs:455`, `dial.rs:488`; halve per-attempt client timeout), then add
|
|
||||||
`.fips_timeout(...)`: `content.rs:366` (6s), `content.rs:281` (8s), `content.rs:1139`
|
|
||||||
(6s), `typed_messages.rs:822` (8s), `dwn_sync.rs:188/213/272` (6s),
|
|
||||||
`node_message.rs:376` (8s), `node_message.rs:412` (4s), `tor/mod.rs:501` (6s),
|
|
||||||
`federation/handlers.rs:869` (6s). **Skip the three 900s streaming downloads**
|
|
||||||
(`content.rs:552/870/1061`, `proxy.rs:236`) — `dial.rs:311-319` documents why; the
|
|
||||||
retry-budget wrap covers their connect phase.
|
|
||||||
|
|
||||||
### RC4 — Two features are 100% Tor by construction (allowlist 404)
|
|
||||||
|
|
||||||
The peer listener path allowlist (`server.rs:1219-1239`) omits `/blob/<cid>` (mesh
|
|
||||||
file sharing, `typed_messages.rs:813-822`) and `/dwn/health` (step 1 of DWN sync,
|
|
||||||
`dwn_sync.rs:186`) → deterministic 404 over FIPS (`dial.rs:44-46` treats 404 as
|
|
||||||
fall-back) → deterministic Tor, after paying the full FIPS cost. Both endpoints are
|
|
||||||
already cryptographically gated, so they meet the allowlist's stated criterion.
|
|
||||||
|
|
||||||
**Fix:** add `|| path.starts_with("/blob/") || path.starts_with("/dwn/")`; extend the
|
|
||||||
existing test block at `server.rs:1935-1945` (assert `/blob/abc` + `/dwn/health`
|
|
||||||
allowed, `/blobber` + `/dwnx` denied).
|
|
||||||
|
|
||||||
### RC5 — Inbound listener can't heal; anchor flap = 5-minute Tor window; probe overhead
|
|
||||||
|
|
||||||
- `peer_late_bind_loop` returns after first successful bind (`server.rs:1203`) and
|
|
||||||
`accept_loop` `continue`s on errors forever (`server.rs:1249-1258`): a fips0
|
|
||||||
teardown/re-key leaves the node inbound-dead until process restart → **every peer**
|
|
||||||
falls back to Tor against it.
|
|
||||||
- Nothing reacts to anchor-link drops: anchors re-apply only on the 300s tick
|
|
||||||
(`server.rs:731`); worst-case 5min Tor-only after a flap (the historic "link dead
|
|
||||||
timeout 30s" flapping made this chronic).
|
|
||||||
- `is_service_active()` spawns up to 2 `systemctl` per FIPS attempt *and* per peer
|
|
||||||
per 25s warm tick (`dial.rs:284-294`); `warm_path` skips peers without
|
|
||||||
`fips_npub` in federation storage (`fips/mod.rs:88-95`); `anchors::apply` is
|
|
||||||
serial with unbounded subprocess waits (`anchors.rs:234-283`).
|
|
||||||
|
|
||||||
**Fix:** rebindable listener; a ~25s connectivity watcher (reuse
|
|
||||||
`service::peer_connectivity_summary`, `fips/service.rs:178-207`) that re-applies
|
|
||||||
anchors immediately on a connected→disconnected edge with bounded backoff; 10s TTL
|
|
||||||
cache for `is_service_active` (mirror `transport/fips.rs:24-107`); warm the union of
|
|
||||||
federation+registry peers; make `apply()` concurrent with per-connect timeouts.
|
|
||||||
|
|
||||||
### RC6 — Zero observability: fallbacks are invisible, so "uptime" is unfalsifiable
|
|
||||||
|
|
||||||
Fallbacks log at `debug!` only (`dial.rs:458,491`); no counters; `last_transport` is
|
|
||||||
written by only 7 of ~20 call sites and **never read** to influence anything
|
|
||||||
(`storage.rs:120-147`). The parallel `TransportRouter` system can't even see FIPS
|
|
||||||
(`FipsTransport` is never constructed — `server.rs:422-442` registers Tor/Mesh/LAN
|
|
||||||
only).
|
|
||||||
|
|
||||||
**Fix:** per-reason fallback counters (F1 no-npub / F2 service-inactive / F3
|
|
||||||
DNS-fail / F4 connect-fail / F5 404 / F6 5xx) surfaced in `fips.status` + `info!`
|
|
||||||
logs with a `reason` field; call `record_peer_transport` from all peer-dial sites;
|
|
||||||
UI: per-peer transport badge on Cloud (the response already carries `transport` —
|
|
||||||
`content.rs:392-400` — Cloud.vue currently throws it away at `:716-721`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part A — execution phases
|
|
||||||
|
|
||||||
### Phase A0 — fleet triage (no release needed; do first, validates everything)
|
|
||||||
1. Fleet audit: `fipsctl --version` + `nft list table inet fips` + `ss -tlnp | grep 5679`
|
|
||||||
on every node (roster: `reference_test_deploy_roster`).
|
|
||||||
2. Transient `nft insert rule inet fips inbound iifname fips0 tcp dport 5679 accept`
|
|
||||||
on hardened nodes (already done on .116 + .198, 2026-07-27) — instant fleet-wide
|
|
||||||
FIPS recovery while the code fix rides the OTA.
|
|
||||||
3. Upgrade .228 (and any other 0.3.x) fips daemon to v0.4.1.
|
|
||||||
4. Regenerate/clean stale `seed-anchors.json` on .116 (dead 192.168.1.x + :8668 entries).
|
|
||||||
5. Baseline measurement: for each node pair, `content.browse-peer` time + transport.
|
|
||||||
|
|
||||||
### Phase A1 — P0 code (one commit, mechanical, offline-testable)
|
|
||||||
1. **nft drop-in: open 5679** — `fips/config.rs` (share the constant with
|
|
||||||
`dial.rs::PEER_PORT`). ← RC0
|
|
||||||
2. **Allowlist `/blob/`, `/dwn/`** — `server.rs:1219-1239` + tests. ← RC4
|
|
||||||
3. **`FIPS_UDP_PORT` = `PUBLISHED_UDP_PORT` (2121)** — `anchors.rs:293` + drift-guard
|
|
||||||
test against `render_config_yaml()`. ← RC2-G2
|
|
||||||
4. **Un-deaden `lan_fips_anchors`** — hydrate `fips_npub` from federation storage in
|
|
||||||
`server.rs:761-766`; then mDNS TXT `fips` key + `set_fips_npub`
|
|
||||||
(`transport/lan.rs:50-54`, `lan.rs:96-108`, `LanTransport::new` 4th arg via
|
|
||||||
`crate::identity::fips_npub(&data_dir.join("identity"))`). ← RC2-G1
|
|
||||||
5. **Retry-budget wrap + `fips_timeout` on 12 call sites** (list in RC3). ← RC3
|
|
||||||
Verify: `cd core && cargo test -p archipelago` — watch `test_rendered_yaml_exact_snapshot`
|
|
||||||
(`config.rs:419`) + `test_render_is_deterministic` (`config.rs:476`); item 3 must
|
|
||||||
not change rendered output.
|
|
||||||
|
|
||||||
### Phase A2 — telemetry BEFORE tuning (second commit)
|
|
||||||
6. Fallback counters by reason + `fips.status` exposure + `info!` reason logs;
|
|
||||||
`record_peer_transport` from all sites. ← RC6 (gives the baseline that makes A3
|
|
||||||
measurable and "100%" falsifiable)
|
|
||||||
|
|
||||||
### Phase A3 — resilience (third commit, measured against A2 baseline)
|
|
||||||
7. `is_service_active` 10s TTL cache; warm-path union + `warm_path_unchecked`.
|
|
||||||
8. Link-state watcher → immediate anchor re-apply on drop (replaces waiting for the
|
|
||||||
300s tick); concurrent `apply()` with subprocess timeouts.
|
|
||||||
9. Rebindable peer listener (`server.rs:1203`, `1249-1258`).
|
|
||||||
10. (Reviewed, separate PR) endpoint-fallback for direct peering: LAN → Tailscale →
|
|
||||||
last-known-good, npub-keyed. Mesh-routing area — needs careful review per memory.
|
|
||||||
|
|
||||||
### Phase A4 — verification gate (on nodes, before tag)
|
|
||||||
- On .116/.198/framework-pt/.228: `content.browse-peer` to every peer must return
|
|
||||||
`transport: "fips"` with sub-second latency (LAN pairs) / <3s (WAN), 20/20 calls.
|
|
||||||
- Kill the fips daemon on one node → calls fall back to Tor gracefully within the
|
|
||||||
fast-fail budget (<8s), UI shows partial results, no errors.
|
|
||||||
- Restart daemon → FIPS recovers within one watcher tick (~25s), verified in
|
|
||||||
`fips.status` counters.
|
|
||||||
- Flap the anchor link (drop vps2 route) → direct LAN pairs keep FIPS via their
|
|
||||||
direct link (G1 fix proof).
|
|
||||||
- Add these as `tests/multinode/` cases per `docs/multinode-testing-plan.md`; also
|
|
||||||
fix the known `node_rpc()` missing `--max-time` (tracker item).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part B — optimistic loading + state management (frontend)
|
|
||||||
|
|
||||||
Full audit: Pinia exists but pages fetch-on-mount with `loading=true` spinners;
|
|
||||||
`Dashboard.vue:89` keys the router-view by `route.path`, so **every navigation
|
|
||||||
unmounts and refetches everything**; no KeepAlive/onActivated anywhere; no dedup,
|
|
||||||
no abort, no SWR layer. Four hand-rolled cache implementations already exist and
|
|
||||||
prove the pattern (`useFleetData.ts:198-231` sessionStorage hydrate;
|
|
||||||
`homeStatus.ts` sticky-ready loadState; `Home.vue:591-621` wallet localStorage
|
|
||||||
snapshot; `curatedApps.ts:21-77` TTL cache). `SkeletonCard.vue` exists, imported by
|
|
||||||
zero files.
|
|
||||||
|
|
||||||
### B1 — one shared primitive: `useCachedResource` composable + `resources` Pinia store
|
|
||||||
Semantics (generalize `homeStatus.ts` + `useFleetData.ts`):
|
|
||||||
- Keyed resource: `{ data, loadState: idle|loading|ready|error|refreshing, fetchedAt, error }`.
|
|
||||||
- **Hydrate synchronously** from memory (Pinia, survives navigation) → sessionStorage
|
|
||||||
snapshot (survives reload) → then revalidate in background.
|
|
||||||
- Sticky-ready: once `ready`, never regress to `loading`
|
|
||||||
(`loadState = loadState==='ready' ? 'ready' : 'loading'` — the `homeStatus.ts:80` idiom);
|
|
||||||
keep-last-known-value on error with a stale badge (age from `fetchedAt`).
|
|
||||||
- TTL per resource; `revalidateOnFocus` + on WS push (debounced, the
|
|
||||||
`Home.vue:539-542` pattern); explicit `invalidate(key)` for mutations.
|
|
||||||
- Optimistic mutation helper: apply → RPC → rollback on error (generalize
|
|
||||||
`TransportPrefsCard.vue:112-127`).
|
|
||||||
|
|
||||||
### B2 — rpc-client upgrades (`src/api/rpc-client.ts`)
|
|
||||||
- `AbortSignal` in `RPCOptions` (today the AbortController at `:87` is timeout-only)
|
|
||||||
→ abort-on-unmount for fan-outs.
|
|
||||||
- In-flight dedup keyed `method+JSON(params)` — collapses duplicate concurrent calls.
|
|
||||||
- Per-call `maxRetries` override; set `maxRetries: 1` for `content.browse-peer` /
|
|
||||||
`preview-peer` (retry×3 on a 30s timeout is why one slow peer = 90s spinner).
|
|
||||||
|
|
||||||
### B3 — Cloud page conversion (worst offender, the marquee win)
|
|
||||||
- Move `sectionCounts`, `peerNodes`, `myFiles`, `peerFiles`, `paidItems` out of
|
|
||||||
`Cloud.vue` component state (`:403,:476,:582,:689,:427`) into the cached store —
|
|
||||||
instant render on revisit, background refresh.
|
|
||||||
- **Incremental per-peer fan-in**: render each peer's card as its
|
|
||||||
`content.browse-peer` resolves (today `Promise.allSettled` at `:708-747` blocks on
|
|
||||||
the slowest peer). Per-peer states: cached/fresh/loading/unreachable.
|
|
||||||
- **Surface `transport` per peer** (already in the response, discarded at `:716-721`):
|
|
||||||
FIPS/Tor badge + latency — this is also the fleet-wide FIPS-uptime dashboard the
|
|
||||||
user asked for, for free.
|
|
||||||
- Skeleton cards (revive `SkeletonCard.vue`, copy `FileGrid.vue:3-19` shimmer) instead
|
|
||||||
of spinners for counts/folders/peer grids.
|
|
||||||
- Stop `CloudFolder.vue:307-319` calling `cloudStore.reset()` on every folder entry —
|
|
||||||
cache per-path listings, navigate renders cache + revalidates.
|
|
||||||
- `PeerFiles.vue`: persist catalog + preview cache in the store; cap the
|
|
||||||
`preview-peer` fan-out (`:832-841`, currently unbounded) with a small concurrency
|
|
||||||
queue + abort-on-unmount.
|
|
||||||
|
|
||||||
### B4 — roll out to remaining offenders (in audit order)
|
|
||||||
PeerFiles → Web5 wallet/ecash/LND slices → Monitoring → Lightning channels
|
|
||||||
(`LightningChannelsPanel.vue:650`) → Federation (already has `{showLoader:false}` —
|
|
||||||
just adopt the store) → Server → Credentials/OpenWrtGateway/ContainerApps.
|
|
||||||
`Apps.vue`/`Marketplace.vue`/`Fleet.vue` are already good; don't touch.
|
|
||||||
|
|
||||||
### B5 — freshness via the existing push channel
|
|
||||||
`/ws/db` firehose + `sync.ts` JSON-patch already exist. Wire `useCachedResource`
|
|
||||||
revalidation to relevant WS pushes (debounced 800ms), keep the 30s staleness
|
|
||||||
reconciliation as backstop. No new backend needed for v1; a per-topic subscribe can
|
|
||||||
come later.
|
|
||||||
|
|
||||||
### Part B verification (on nodes)
|
|
||||||
- Navigate Cloud → Apps → Cloud: peer files render instantly from cache (0 spinner),
|
|
||||||
refresh indicator while revalidating, updated data lands without layout jump.
|
|
||||||
- One unreachable peer: its card shows stale/unreachable state; other peers render
|
|
||||||
immediately (no 30s all-or-nothing).
|
|
||||||
- Kill backend mid-view: stale data stays visible with age badge; recovery
|
|
||||||
revalidates automatically.
|
|
||||||
- Hard reload: sessionStorage hydrate paints before first RPC completes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sequencing for the next release
|
|
||||||
|
|
||||||
1. **A0 now** (fleet triage + transient nft rules + .228 daemon upgrade + baseline).
|
|
||||||
2. **A1 + A2** land together (P0 fixes + telemetry) → deploy to .116/.198 →
|
|
||||||
Phase A4 checks on the pair → framework-pt → full fleet.
|
|
||||||
3. **B1 + B2 + B3** (composable + rpc-client + Cloud) in parallel with A-testing —
|
|
||||||
frontend-only, verifiable against .116 dev (`reference_neode_ui_dev_testing`).
|
|
||||||
4. **A3** after telemetry baseline exists; **B4/B5** ride the same or next OTA.
|
|
||||||
5. Gate: Phase A4 checklist green + Part B verification on-device + existing
|
|
||||||
single-node gate stays green → tag/OTA per ship ritual.
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
- `content.browse-peer` transport = fips for ≥99% of calls between healthy 0.4.1
|
|
||||||
nodes over 24h (measured by the new counters), Tor reserved for genuinely
|
|
||||||
FIPS-unreachable peers (.116-WiFi-class networks).
|
|
||||||
- Cloud revisit paints in <100ms from cache; fresh data within one revalidate.
|
|
||||||
- Fallback counters visible in `fips.status` so regressions are caught on the
|
|
||||||
dashboard, not by users.
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
# License Compliance Audit — Open-Source Release
|
|
||||||
|
|
||||||
Audit date: 2026-07-22. Scope: entire repo (core Rust workspace, neode-ui, apps/*, Android companion, image-recipe ISO, docker/, app-catalog, reticulum-daemon, demo/) plus the external FIPS source and registry-mirrored images.
|
|
||||||
|
|
||||||
**Verdict:** the dependency graph is almost entirely permissive (MIT/Apache/BSD) and compatible with a free open-source release. But the repo is not releasable as-is: it has **no license of its own**, one **LGPL Rust dependency**, several **non-redistributable committed assets** (proprietary fonts, unknown-rights media), and **missing attribution machinery**. Everything below is ordered by severity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## STATUS UPDATE — 2026-07-23
|
|
||||||
|
|
||||||
**DONE:**
|
|
||||||
- MIT adopted. Root `LICENSE` + `NOTICE` added; `license = "MIT"` in all 5 workspace crates (archy-fips-core already had it); `"license": "MIT"` (+ `"private": true`) in all 4 package.json files.
|
|
||||||
- Deleted: `Courier_New/`, `Benton_Sans/`, `Redacted/` fonts; `wireguard.apk`; `atob.s9pk`; obsolete `test-install.sh` (all git-rm'd; also removed from `web/dist`).
|
|
||||||
- Media provenance resolved: all demo music/photos/posters, UI sfx, backgrounds, and intro video are the author's original work — recorded in `demo/content/README.md` and `NOTICE`.
|
|
||||||
- Meshtastic device artwork attributed (`mesh-devices/ATTRIBUTION.md` + NOTICE); icon attribution added (`assets/icon/ATTRIBUTION.md`: game-icons.net CC BY 3.0, pixelarticons MIT).
|
|
||||||
- Reticulum decision: include + disclose (NOTICE states the Reticulum License restrictions and that it applies only to the optional daemon).
|
|
||||||
- indeedhub: deferred — partnership in place; license the submodule before/at public release.
|
|
||||||
- License inventories generated: `core/THIRD-PARTY-LICENSES.md` (649 crates) and `neode-ui/THIRD-PARTY-LICENSES.md` (runtime deps + fonts + vendored).
|
|
||||||
|
|
||||||
**REMAINING (code changes, awaiting review — see sections below for detail):**
|
|
||||||
1. Replace `zbase32` (LGPL-3.0+) with `z32` or original impl — §2.
|
|
||||||
2. Swap `redis:7.4.8` → Valkey in `scripts/image-versions.sh` and deploys — §3.
|
|
||||||
3. Delete dead StartOS-derived crates `core/{js-engine,container-init,models,helpers}` — §4.
|
|
||||||
4. Attribution build integration: cargo-about in CI → ship full license texts in ISO; vite/rollup license plugin (or UI licenses page) for the web bundle; Android OSS-licenses screen — §5.
|
|
||||||
5. Release-checklist items: per-release Debian source pointer (snapshot.debian.org), catalog `license`/`sourceUrl` fields, restrict ISO image bundling to the audited list — §6.
|
|
||||||
6. Before repo goes public: purge deleted fonts/APKs from git history (`git filter-repo`), and verify game-icons author credit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. BLOCKER — the project has no license
|
|
||||||
|
|
||||||
There is no `LICENSE`/`COPYING` file anywhere in the repo. No crate in `core/` declares a `license` field; none of the four `package.json` files do either (and the three `apps/*` packages aren't even `private: true`). Until fixed, the code is "all rights reserved" — publicly visible, but legally not open source and not usable by anyone.
|
|
||||||
|
|
||||||
**Do:**
|
|
||||||
- [ ] Choose a license. **Recommendation: MIT** — the Bitcoin-ecosystem norm (Bitcoin Core, LND are MIT), maximally compatible with everything found in the graph. (Alternatives: Apache-2.0 adds a patent grant; GPLv3 if copyleft is desired — nothing in the deps prevents any of these.)
|
|
||||||
- [ ] Add `LICENSE` at repo root with the year and copyright holder.
|
|
||||||
- [ ] Add `license = "MIT"` to all five workspace member `Cargo.toml`s (archipelago, container, openwrt, performance, security) and `Android/rust/archy-fips-core` (declares MIT but ships no license file — add one).
|
|
||||||
- [ ] Add `"license": "MIT"` to `neode-ui/package.json` and `apps/{morphos-server,router,did-wallet}/package.json`.
|
|
||||||
|
|
||||||
## 2. BLOCKER — copyleft dependency that must be replaced
|
|
||||||
|
|
||||||
- [ ] **`zbase32 0.1.2` — LGPL-3.0+** — the only hard copyleft blocker in all 649 resolved Rust crates. Direct dep of `archipelago`, used in `core/archipelago/src/network/did_dht.rs` for did:dht z-base-32 encoding. LGPL statically linked into a Rust binary requires shipping relinkable objects/source — impractical. **Replace with the MIT `z32` crate** or a ~30-line original alphabet-substitution implementation.
|
|
||||||
|
|
||||||
No GPL, AGPL, SSPL, or unlicensed crates exist anywhere else in the Rust graph. (`r-efi` and `self_cell` list LGPL/GPL only as options in OR-expressions — elect MIT/Apache, no action.)
|
|
||||||
|
|
||||||
## 3. BLOCKER — committed files we may not redistribute
|
|
||||||
|
|
||||||
Remove from git (and **purge from history** before the repo goes public — they're in past commits):
|
|
||||||
|
|
||||||
- [ ] `neode-ui/public/assets/fonts/Courier_New/` — Monotype proprietary font, no license, **unused in CSS**. Delete.
|
|
||||||
- [ ] `neode-ui/public/assets/fonts/Benton_Sans/BentonSans-Regular.otf` — commercial Font Bureau typeface, no license, unused. Delete.
|
|
||||||
- [ ] `neode-ui/public/packages/wireguard.apk` (17 MB) — official WireGuard Android APK containing GPL-2.0 `libwg` components; redistribution triggers GPL source-offer. **Unreferenced since the FIPS migration** — delete.
|
|
||||||
- [ ] `neode-ui/public/packages/atob.s9pk` (24 MB) — Start9 service package, unknown license, referenced only by a test script. Delete.
|
|
||||||
- [ ] `demo/content/music/` (18 full tracks, ~150 MB) and `demo/peer-media/` (17 photos/book covers/film posters) — no recorded rights. If they're your own/AI-generated work, document that in a `demo/content/README`; otherwise remove.
|
|
||||||
- [ ] `neode-ui/public/assets/video/video-intro.mp4`, `Kratter.MP3`, photographic `bg-*.jpg` backgrounds, UI/arcade sound effects in `assets/audio/` — same: document provenance (user-made per project convention) or replace. `welcome-noderunner.mp3` is ElevenLabs TTS — their commercial-use terms allow this on paid plans; note it.
|
|
||||||
- [ ] **Registry: `redis:7.4.8`** (`scripts/image-versions.sh` `REDIS_IMAGE`) — Redis ≥ 7.4 is RSALv2/SSPLv1, **not open source**; re-hosting it on your registry is redistribution under a restricted license. **Switch to Valkey** (BSD-3, already mirrored) everywhere.
|
|
||||||
|
|
||||||
## 4. VERIFY — unknown/third-party provenance
|
|
||||||
|
|
||||||
- [ ] **`neode-ui/public/assets/img/mesh-devices/` (36 SVGs)** — almost certainly Meshtastic project device artwork (meshtastic/web is GPL-3.0). Confirm source; either replace with original art or comply with the upstream license + attribution.
|
|
||||||
- [ ] **`neode-ui/public/assets/icon/`** — `barbarian.svg`, `batteries.svg` match game-icons.net (**CC BY 3.0 — visible attribution required**); pixel-style icons match pixelarticons (MIT). Confirm and add attribution, or replace.
|
|
||||||
- [ ] `Redacted/redacted.regular.ttf` — upstream is SIL OFL 1.1 but no license file is shipped. Add `OFL.txt` or delete (unused).
|
|
||||||
- [ ] **indeedhub** — submodule (private gitea) not checked out; no known license, yet `indeedhub{,-api,-ffmpeg}:1.0.0` images are distributed via registry/ISO. `indeedhub-ffmpeg` implies a bundled FFmpeg (LGPL/GPL → source-offer obligations). Must license the project and audit the ffmpeg build before public release.
|
|
||||||
- [ ] `minmoto/fmcd` v0.8.0 and `ark-bitcoin/bark` (barkd) — binaries redistributed in your images; verify upstream licenses (bark claims Apache-2.0/MIT dual) and include their notices.
|
|
||||||
- [ ] **Start9/StartOS heritage** — `core/{js-engine,container-init,models,helpers}` are StartOS-derived (embassy paths, s9pk handling). start-os is MIT → attribution required if kept. **Better: delete these four crates** — they are not workspace members, cannot compile (broken `../../patch-db` path dep), and carry an unpinned `yajrc = "*"` git dep on a moving branch. Deleting removes both the attribution question and dead code.
|
|
||||||
- [ ] **Reticulum (RNS 1.3.5 + LXMF)** — verified: custom "Reticulum License" — MIT-style **plus field-of-use restrictions** (no systems designed to harm humans; no AI/ML training-dataset use). Redistribution is permitted, so shipping the PyInstaller `archy-reticulum-daemon` binary is fine **if** the license text is included with it — but the OS cannot claim to be 100 % OSI-open-source while bundling it. Options: include + disclose (recommended, matches "plan for decentralization" honesty), or make the daemon an optional download.
|
|
||||||
|
|
||||||
## 5. REQUIRED — attribution / notice machinery (currently absent)
|
|
||||||
|
|
||||||
Nearly every permissive license (MIT/BSD/ISC/Apache) requires reproducing copyright + license text **in distributed binaries** — and right now every distribution channel strips them:
|
|
||||||
|
|
||||||
- [ ] **Rust binaries** (649 crates, ~85 % MIT/Apache dual): generate `THIRD-PARTY-LICENSES` with `cargo-about` (or `cargo-license`) in CI; ship it in the ISO at e.g. `/usr/share/doc/archipelago/`. Include **ring's three license files** (LICENSE, LICENSE-BoringSSL, LICENSE-other-bits) and note the system OpenSSL (Apache-2.0) linked via `ssh2`.
|
|
||||||
- [ ] **Web bundle**: Vite/esbuild strips all `@license` comments from `web/dist`. Add `rollup-plugin-license`/`vite-plugin-license` to emit a third-party attribution file, or add an "Open-source licenses" page in the UI. Runtime deps needing notices: vue/vue-router/pinia/vue-i18n (MIT), d3 (ISC), leaflet (BSD-2), dompurify (elect Apache-2.0 of its MPL/Apache dual), fuse.js (Apache-2.0), qrcode/qr-scanner/qrloop/buffer/fast-json-patch (MIT).
|
|
||||||
- [ ] **Android APK**: `packaging.excludes` strips `META-INF` license texts and there is no licenses screen. Add an OSS-licenses screen or bundled `licenses.txt` covering AndroidX/Compose/OkHttp/ZXing (Apache-2.0), **fips © 2026 Johnathan Corgan (MIT — the core of the VPN feature)**, tokio/tracing (MIT), subtle (BSD-3), tun (WTFPL — permissive, just list it), secp256k1 family (CC0). Generate the Rust side from the committed `Cargo.lock` with cargo-about.
|
|
||||||
- [ ] **AIUI demo bundle** (`demo/aiui/` — committed minified build): bundles Mermaid, Cytoscape, KaTeX, D3, Lodash, Workbox (all MIT/BSD). Add a `THIRD-PARTY-LICENSES` file next to it (or rebuild with a license plugin).
|
|
||||||
- [ ] Keep the intact MIT headers in the two vendored `qrcode.js` copies (docker/lnd-ui, docker/electrs-ui) — already compliant, don't minify them.
|
|
||||||
- [ ] Fonts kept: Montserrat (OFL.txt present ✓), Open Sans (Apache LICENSE.txt present ✓) — keep license files adjacent to the font files in dist.
|
|
||||||
|
|
||||||
## 6. REQUIRED — distribution-level obligations (ISO & registry)
|
|
||||||
|
|
||||||
The ISO redistributes a full Debian (trixie) system plus ~29 container image tarballs; the private registry re-hosts upstream images. Re-hosting = redistribution, same obligations as bundling.
|
|
||||||
|
|
||||||
- [ ] **GPL source offer for the ISO** — kernel, GRUB, busybox/live-boot, coreutils, nftables, cryptsetup, wireguard-tools, SYSLINUX `isohdpfx.bin`, etc. Easiest compliance: keep `/usr/share/doc/*/copyright` (the build already does ✓) **and** publish, per release, either a mirror of the exact Debian source packages (`apt-get source` snapshot / snapshot.debian.org pointer) or a written offer in the docs. Add this to the release checklist.
|
|
||||||
- [ ] **AGPLv3 images redistributed** (mempool, Grafana, Vaultwarden, SearXNG, PhotoPrism, Nextcloud, Immich, CryptPad, MinIO): AGPL compliance = make corresponding source available. You ship a **modified** mempool-frontend (`docker/mempool-frontend` entrypoint patch) — the patch is in-repo, so compliance is met once the repo is public; state this in docs. For unmodified images, link upstream sources in the app catalog.
|
|
||||||
- [ ] **GPLv2/GPLv3 images** (MariaDB, Jellyfin, AdGuard Home, strfry): unmodified redistribution → provide license text + upstream source links (a `license` + `sourceUrl` field per `app-catalog/catalog.json` entry solves this catalog-wide).
|
|
||||||
- [ ] **Non-free firmware** (firmware-realtek/iwlwifi/misc/linux-nonfree, intel/amd microcode): redistributable but proprietary — disclose in docs ("includes non-free firmware for hardware support"), like Debian's own non-free-firmware ISOs do.
|
|
||||||
- [ ] The ISO build's live-server image capture (`podman save` of whatever matches on the dev server) is a compliance hazard — bundle only from the audited image list.
|
|
||||||
- [ ] FIPS daemon (jmcorgan/fips v0.4.1, MIT ✓) and nostr-rs-relay binary (MIT ✓): include their license texts in the notices bundle.
|
|
||||||
|
|
||||||
## 7. Housekeeping (supports compliance)
|
|
||||||
|
|
||||||
- [ ] Add lockfiles + pinned versions in `apps/*` (currently floating `^` ranges, violating the project's own pinning rule) — reproducibility is also what makes license audits stay true.
|
|
||||||
- [ ] `Android` fips dep is pinned to a personal fork rev (`9qeklajc/fips-native@46494a74`) — mirror or vendor it so outside contributors can build.
|
|
||||||
- [ ] Move `@types/dompurify` to devDeps; refresh stale `neode-ui/node_modules`.
|
|
||||||
- [ ] Add a `NOTICE` file at root naming: fips (Johnathan Corgan, MIT), Start9 start-os (if any derived code remains), Kazuhiko Arase qrcode.js, font licenses, icon attributions.
|
|
||||||
- [ ] Consider CI license gating: `cargo-deny` (Rust) + `license-checker` (npm) with an allowlist, so new copyleft deps are caught at PR time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick reference: what's already clean
|
|
||||||
|
|
||||||
- All 649 Rust crates except `zbase32`: permissive or dual-licensed.
|
|
||||||
- All 833 npm packages in neode-ui: no GPL/AGPL anywhere; only dev-tool LGPL (sharp's libvips, never distributed).
|
|
||||||
- Android Gradle deps: 100 % Apache-2.0, all pinned, no Play Services/telemetry.
|
|
||||||
- FIPS mesh: MIT (© 2026 Johnathan Corgan) — keep notice.
|
|
||||||
- js-engine binds deno_core (MIT) as a crate, nothing vendored — moot if dead crates are deleted.
|
|
||||||
- reticulum-daemon Python is original code; obligations attach only to the PyInstaller binary (see §4).
|
|
||||||
- Bitcoin Core/Knots, LND, BTCPay, Electrs, Fedimint, core-lightning, Gitea, Home Assistant, Tailscale, Portainer, Uptime-Kuma, filebrowser, ollama, penpot: MIT/Apache/BSD/Zlib/MPL — link + notice is enough.
|
|
||||||
@ -1,288 +0,0 @@
|
|||||||
# Open-Source Readiness Plan — Archipelago public launch
|
|
||||||
|
|
||||||
> Working plan, 2026-07-27. Source of truth for the pre-open-source cleanup.
|
|
||||||
> A second agent is working the same goal concurrently — before executing any phase,
|
|
||||||
> diff against `git log` since `7e8d3314` and skip/merge what's already done.
|
|
||||||
> (Session plan file: `~/.claude/plans/resilient-moseying-reef.md`.)
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The repo goes public in a few days, targeting bitcoin/bitcoin-level polish. Three deep
|
|
||||||
exploration passes (docs/structure, code health, secrets sweep) found the repo is
|
|
||||||
fundamentally strong — README, `apps/` manifest examples, ADRs, the bats lifecycle gate,
|
|
||||||
1,104 Rust tests — but has hard blockers: **two live Anthropic API keys committed in
|
|
||||||
tracked files**, node passwords in 7 tracked files, no LICENSE (README links a 404),
|
|
||||||
5.5 GB `.git` (re-committed 27 MB APKs), ~290 hardcoded references to the private Gitea
|
|
||||||
registry `146.59.87.168:3000` that make every app image unpullable for outsiders, and
|
|
||||||
~28 internal AI-session/tracker docs mixed into `docs/`.
|
|
||||||
|
|
||||||
**Decisions made by the user:**
|
|
||||||
1. **Fresh-history publish** — new public repo with a clean initial commit; private repo keeps full history.
|
|
||||||
2. **Registry: domain + parameterize** — real domain in front of the existing registry; host configurable everywhere.
|
|
||||||
3. **Deep code cleanup** — orphan crates, dead_code lifts, clippy trims, legacy fallback deletion (sequenced, cut-line-friendly).
|
|
||||||
4. **Internal docs: sanitize and keep public** — scrub creds/IPs/hostnames but publish plans/trackers for transparency.
|
|
||||||
|
|
||||||
**Invariant throughout:** the single-node production gate (`tests/lifecycle/run-gate.sh`)
|
|
||||||
is GREEN and must stay green. Re-run after any orchestrator/lifecycle change (Phase E
|
|
||||||
especially). All cargo verification uses `--all-features` to match CI. Stage by explicit
|
|
||||||
path, never `git add -A` (shared tree).
|
|
||||||
|
|
||||||
## Current local pass status
|
|
||||||
|
|
||||||
This branch is replayed on top of `origin/main` as `public-prelaunch`.
|
|
||||||
|
|
||||||
Completed locally in this pass:
|
|
||||||
|
|
||||||
- Redacted the two tracked Anthropic API key literals from
|
|
||||||
`scripts/setup-aiui-server.sh` and
|
|
||||||
`image-recipe/_archived/build-auto-installer-iso.sh`.
|
|
||||||
- Removed `Android/app/debug.keystore` and `core/.env.production` from the
|
|
||||||
source tree; copies were preserved in
|
|
||||||
`~/Desktop/archipelago-sensitive-backup-2026-07-27/`.
|
|
||||||
- Reworked `scripts/audit-secrets.sh` to scan tracked source more aggressively
|
|
||||||
and to catch non-example env files and credential file patterns.
|
|
||||||
- Reworked `scripts/validate-app-manifest.sh` so the current `app:` manifest
|
|
||||||
schema can be audited without a Python `PyYAML` dependency.
|
|
||||||
- Updated root/community docs, CI, PR template, app developer notes, and
|
|
||||||
container/deployment docs toward public contributor expectations.
|
|
||||||
- Fixed native FIPS activation fallback: nodes that have the packaged
|
|
||||||
`fips.service` but not `archipelago-fips.service` now start the available
|
|
||||||
unit instead of repeatedly failing activation against a missing unit. This
|
|
||||||
now covers startup, supervisor self-heal, manual dashboard start/reconnect,
|
|
||||||
and post-onboarding activation. The UI now labels the action as `Start`
|
|
||||||
instead of making native FIPS look like an installable app.
|
|
||||||
- Fixed the FIPS app-port relay design so it binds relays to the node's FIPS
|
|
||||||
ULA instead of wildcard `[::]`, avoiding collisions with Podman-published app
|
|
||||||
ports such as FileBrowser `8083` and Botfights `9100`.
|
|
||||||
- Added `docs/nostr-git-source-hosting.md`, a NIP-34/ngit/GRASP source hosting
|
|
||||||
plan using a Bitcoin Core-style maintainer model: public review and easy
|
|
||||||
forks, with canonical merge rights held by a small signed maintainer set.
|
|
||||||
|
|
||||||
Verified locally:
|
|
||||||
|
|
||||||
- `./scripts/audit-secrets.sh` passes.
|
|
||||||
- Full `apps/*/manifest.yml` repository audit passes with warnings only.
|
|
||||||
- `bash -n` passes for the edited shell scripts.
|
|
||||||
- Targeted FIPS dashboard vitest passes.
|
|
||||||
- Targeted Rust tests for FIPS service unit detection and FIPS app relay
|
|
||||||
address selection pass.
|
|
||||||
|
|
||||||
Verified on a Linux Archipelago verification node:
|
|
||||||
|
|
||||||
- Native FIPS was restored by starting the already-installed packaged
|
|
||||||
`fips.service`; the daemon became active and joined the FIPS tree.
|
|
||||||
- Correct local lifecycle API endpoint is HTTP, not HTTPS
|
|
||||||
(`ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=http`).
|
|
||||||
- Read-only lifecycle run progressed past login and confirmed required
|
|
||||||
containers, Bitcoin RPC, ElectrumX TCP, and manifest port-drift checks, but
|
|
||||||
did not complete cleanly: `botfights` and `filebrowser` remained in
|
|
||||||
`restarting` longer than the matrix window, and the LND `lncli getinfo`
|
|
||||||
probe hung. Do not run the destructive gate until those live-node issues are
|
|
||||||
understood.
|
|
||||||
- After the node updated to `1.7.116-alpha`, `botfights`, `filebrowser`, and
|
|
||||||
`lnd` were active/running and ports `8083`/`9100` were held by Podman's
|
|
||||||
`rootlessport` as expected. The packaged `fips.service` remained installed
|
|
||||||
and enabled but inactive, so the native FIPS service fallback should still
|
|
||||||
ship before the public launch.
|
|
||||||
|
|
||||||
Still required before public publish:
|
|
||||||
|
|
||||||
- Rotate/revoke compromised credentials listed in Phase 0.
|
|
||||||
- Finish Phase 1 password/node/token sanitization beyond the two API keys.
|
|
||||||
- Publish from fresh history after the sanitized tree is final.
|
|
||||||
- Run full Rust, frontend, Android, and lifecycle gate verification.
|
|
||||||
- Resolve the live-node lifecycle blockers above, then rerun the read-only
|
|
||||||
suite followed by the destructive gate only on an approved verification node.
|
|
||||||
- Decide the canonical Archipelago maintainer npub and merge-maintainer npub
|
|
||||||
list before publishing the Nostr Git source-hosting workflow.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 0 — Credential rotation (immediate, independent of the repo)
|
|
||||||
|
|
||||||
Treat all of these as already compromised; rotate even though we're doing fresh-history:
|
|
||||||
|
|
||||||
- **Anthropic API key #1**: `image-recipe/_archived/build-auto-installer-iso.sh:2837` (the "intentional alpha" ISO key). Revoke + reissue; move the live key OUT of source into a build-time secret/env (`ISO_ANTHROPIC_API_KEY`), keep the alpha-baking behavior if desired but never the literal in git.
|
|
||||||
- **Anthropic API key #2**: `scripts/setup-aiui-server.sh:28` — a *different* live key, not covered by the documented alpha exception. Revoke; parameterize the script.
|
|
||||||
- **The shared node SSH/sudo/UI password** (two variants) — in 7 tracked files + 24+ commits. Rotate fleet-wide (user task).
|
|
||||||
- **Gitea `ai` account password + 2 Gitea tokens** — embedded in `.git/config` remote URLs (not tracked, but leaks in any directory copy/tarball). Rotate; switch remotes to credential-helper storage instead of URL-embedded creds.
|
|
||||||
|
|
||||||
## Phase 1 — Secrets & sanitization of tracked files
|
|
||||||
|
|
||||||
1. Strip the password/credential lines from the 7 files:
|
|
||||||
`docs/PRODUCTION-MASTER-PLAN.md` (lines ~428–429, 454–457, 483, 521–528, 886 — the fleet cred table),
|
|
||||||
`docs/archive/SESSION-1.8.0-OTA-PROGRESS.md`, `docs/archive/HANDOVER-2026-07-02-iso-feedback.md`,
|
|
||||||
`docs/bitcoin-version-bulletproof-rollout.md`, `tests/production-quality/TRACKER.md`,
|
|
||||||
`tests/multinode/meshtastic.sh:26`, `neode-ui/test-openwrt.mjs:4` (→ env var).
|
|
||||||
2. `.gitea/workflows/post-install-tests.yml` — remove `sshpass -p '…'` + default target IP; use secrets/vars.
|
|
||||||
3. Sanitize infra identifiers repo-wide (in the *sanitize-and-keep* docs and scripts):
|
|
||||||
replace Tailscale IPs (17 unique, 14 files), LAN IPs (`192.168.1.x`, 93 files), hostnames
|
|
||||||
(`tx1138`, `shorty-s`, `archy-x250`, `archy-dev-pa`) with placeholders like `<node-a>` /
|
|
||||||
`NODE_IP`. Key script targets: `scripts/deploy-config-defaults.sh`, `scripts/deploy-tailscale.sh`,
|
|
||||||
`docs/operations-runbook.md` (opens with real node IPs), `docs/developer-guide.md`, `docs/api-reference.md`, `docs/hotfix-process.md`.
|
|
||||||
4. Fix the audit tool that let this happen: `scripts/audit-secrets.sh:28` — remove `\.md$` and
|
|
||||||
bare `test` from ALLOW_PATTERNS; add `sk-ant-` and password-table patterns; scan all
|
|
||||||
tracked files not just `*.env`. Run it clean as a Phase-1 exit check.
|
|
||||||
5. `.gitignore` additions: `.claude/`, `*.key`, `*.pem`, `id_rsa*`, `*.sqlite`, `*.db`
|
|
||||||
(`.claude/settings.local.json` with creds is currently only ignored by a machine-global rule).
|
|
||||||
6. Product-security note to raise (not fix now): `password123` is a shipped default (auth.rs, en.json, user-walkthrough) — file a public issue for forced first-run password change if not already enforced.
|
|
||||||
|
|
||||||
## Phase 2 — Repo restructure: deletions, binaries, layout
|
|
||||||
|
|
||||||
Delete (each its own commit):
|
|
||||||
- `loop/` (AI overnight harness w/ node SSH lines), `.agents/`, `.codex`, `.githooks/pre-push`
|
|
||||||
(the hook that re-commits the 27 MB APK — root cause of the 5.5 GB history).
|
|
||||||
- `indeedhub/` submodule + `.gitmodules` entry (points at private HTTP Gitea, breaks `--recursive`
|
|
||||||
clones); `indeedhub-demo/` (single Dockerfile — merge or drop).
|
|
||||||
- `RELEASE-NOTES-v1.0.0.md` (superseded by CHANGELOG), `neode-ui/docs/GAMEPAD-NAV-MAP.md` (duplicate of `docs/GAMEPAD-NAV.md`).
|
|
||||||
- Stray generated HTML: `docs/container-architecture.html` (311 KB), `docs/archive/architecture-review.html`, `docs/archive/lora-functionality.html`.
|
|
||||||
- `Android/local.properties` from tracking (local absolute path); remove `Android/app/debug.keystore` (standard practice).
|
|
||||||
|
|
||||||
Move out of git (→ release assets on the Releases page, referenced by URL):
|
|
||||||
- `neode-ui/public/packages/archipelago-companion.apk` (27 MB), `wireguard.apk` (17 MB), `atob.s9pk` (23 MB).
|
|
||||||
- `Android/archipelago-0.3.0-debug.apk.zip` (16 MB, stale).
|
|
||||||
- `demo/content/music/*` + heavy `demo/aiui/assets` (~261 MB, third-party/unclear-licence media — MUST not ship publicly regardless of size).
|
|
||||||
- `neode-ui/dev-dist/` (generated Workbox output) → gitignore.
|
|
||||||
|
|
||||||
Rename/fix the naming lie: `image-recipe/_archived/` contains the *production* ISO builder
|
|
||||||
(`build-auto-installer-iso.sh`, referenced by `.gitea/workflows/build-iso.yml`). Move live
|
|
||||||
files up into `image-recipe/`, delete the genuinely archived rest.
|
|
||||||
|
|
||||||
## Phase 3 — Registry domain + parameterization (functional blocker)
|
|
||||||
|
|
||||||
Infra (user assists: DNS + TLS):
|
|
||||||
- Put a domain (e.g. `registry.archipelago-os.org` / `git.archipelago-os.org`) with HTTPS in
|
|
||||||
front of the existing Gitea on vps2. OTA download URLs move from plain HTTP to HTTPS.
|
|
||||||
|
|
||||||
Repo changes:
|
|
||||||
- Introduce a single source of truth for the registry host (e.g. `REGISTRY_HOST` in
|
|
||||||
`scripts/lib/` + a default in the orchestrator config). Replace `146.59.87.168:3000` in:
|
|
||||||
all 56 `apps/*/manifest.yml`, `app-catalog/catalog.json`, `releases/manifest.json`,
|
|
||||||
`release-manifest.json`, the 11 scripts (`self-update.sh`, `create-release.sh`,
|
|
||||||
`generate-app-catalog.sh`, `validate-app-manifest.sh`, `first-boot-containers.sh`, …),
|
|
||||||
both `demo-images.yml` workflows, `demo-deploy/.env.example`, and the Android sources
|
|
||||||
(`FipsPreferences.kt`, `PartyScreen.kt`).
|
|
||||||
- Because the catalog is signed: regenerate + re-sign + republish the app catalog after the
|
|
||||||
manifest host change (catalog-overlay supremacy — disk edits don't apply otherwise).
|
|
||||||
Signing needs the user's mnemonic → schedule one ceremony after manifests are final.
|
|
||||||
- Verify: fresh machine with no LAN/tailnet access can `podman pull` one app image via the
|
|
||||||
domain and the gate node still installs apps after the re-signed catalog lands.
|
|
||||||
|
|
||||||
## Phase 4 — Documentation overhaul
|
|
||||||
|
|
||||||
### 4a. Community/legal files (missing today)
|
|
||||||
- `LICENSE` — MIT (matches existing README badge). Add `[workspace.package] license` +
|
|
||||||
`license.workspace = true` in the 5 member Cargo.tomls (also see Phase A4).
|
|
||||||
- `SECURITY.md` — disclosure address, PGP key, supported-versions; cite the March 2026 audit (`docs/archive/security-code-audit-2026-03.md`).
|
|
||||||
- `CODE_OF_CONDUCT.md` — Contributor Covenant (CONTRIBUTING.md already links to it, 404 today).
|
|
||||||
- `CONTRIBUTING.md` edits: Gitea→GitHub fork flow, remove private deploy instructions, absorb
|
|
||||||
the public-worthy CLAUDE.md invariants (rootless podman, manifest-driven, secrets model,
|
|
||||||
non-destructive migrations), versioning policy note for the `-alpha` scheme.
|
|
||||||
- `CLAUDE.md` — rewrite: keep invariants/build-verify (public-worthy), remove status banner,
|
|
||||||
node numbers, `gitea-ai` push mechanics, MEMORY references (those move to private notes).
|
|
||||||
|
|
||||||
### 4b. New developer docs (the three real gaps for app developers)
|
|
||||||
1. **`docs/quadlet-compilation.md`** — how a manifest becomes a Quadlet/systemd unit: naming,
|
|
||||||
`systemctl --user` lifecycle, where units land, how to inspect/debug one. (Source:
|
|
||||||
`core/archipelago/src/container/quadlet*.rs`, prod_orchestrator.)
|
|
||||||
2. **`docs/container-lifecycle.md`** — the 30 s level-triggered reconciler, install/adopt/
|
|
||||||
restart/uninstall state machine, health checks, crash recovery. (Replaces the plan-shaped
|
|
||||||
`docs/bulletproof-containers.md` as the current description; salvage its content.)
|
|
||||||
3. **`docs/secrets.md`** — `generated_secrets` declaration → materialisation by
|
|
||||||
`container::secrets` (0600, rootless) → injection; what developers must never do.
|
|
||||||
- Also: make every example in `docs/app-developer-guide.md` + `apps/*/manifest.yml` copy-paste
|
|
||||||
work against the new public registry host; add an end-to-end "write your first app" walkthrough
|
|
||||||
that a stranger can follow with only the public repo + an Archipelago node.
|
|
||||||
|
|
||||||
### 4c. Sanitize-and-keep internal docs (user's transparency choice)
|
|
||||||
- Keep, after Phase-1 scrubbing: `docs/PRODUCTION-MASTER-PLAN.md`, `docs/UNIFIED-TASK-TRACKER.md`,
|
|
||||||
`docs/1.8.0-RELEASE-HARDENING-PLAN.md`, `docs/RETICULUM-TRANSPORT-PROGRESS.md`, HANDOFF-*, test
|
|
||||||
plans, `docs/archive/*` — but **move all session/handoff/tracker material under
|
|
||||||
`docs/history/`** (extending the existing honest `docs/archive/README.md` pattern) so the
|
|
||||||
top-level `docs/` reads as current reference only. Add a banner to each: "historical working
|
|
||||||
document, sanitized; not maintained."
|
|
||||||
- Remove dangling agent-memory references in tracked docs (`docs/bulletproof-containers.md`,
|
|
||||||
`docs/RETICULUM-TRANSPORT-PROGRESS.md`, `docs/registry-manifest-design.md`,
|
|
||||||
`docs/bitcoin-multi-version-design.md` progress block).
|
|
||||||
- De-status the 14 design docs (strip "Status/RESUME POINT" headers into a one-line status
|
|
||||||
field; e.g. `docs/APP-PACKAGING-MIGRATION-PLAN.md` → public app-platform design doc).
|
|
||||||
- Extract North-Star narrative from PRODUCTION-MASTER-PLAN into `docs/ROADMAP.md`; extract
|
|
||||||
the "run the gate ON the node" philosophy from `docs/multinode-testing-plan.md` into
|
|
||||||
`tests/lifecycle/TESTING.md`.
|
|
||||||
- Add `docs/README.md` index (bitcoin/bitcoin `doc/` style): Getting started / Architecture /
|
|
||||||
App development / Operations / Design docs (ADRs) / History.
|
|
||||||
- README fixes: LICENSE link becomes real, Documentation table repointed at the reorganized
|
|
||||||
docs, remove "Deploy to a Test Node" private-LAN section, point Contributing at
|
|
||||||
CONTRIBUTING.md only.
|
|
||||||
|
|
||||||
## Phase 5 — Deep code cleanup (ordered zero-risk → highest-risk; cut-line after any commit)
|
|
||||||
|
|
||||||
### A. Zero-risk deletions & metadata (S each, own commits)
|
|
||||||
- **A1** Delete orphan non-compiling StartOS crates: `core/models`, `core/helpers`,
|
|
||||||
`core/js-engine` (incl. 2 committed `JS_SNAPSHOT.*.bin`), `core/container-init` (~4,100 LOC,
|
|
||||||
zero references). Verify: `cargo build --workspace && cargo test --all-features`.
|
|
||||||
- **A2** Delete unreferenced Vue components: `neode-ui/src/components/{AppSwitcher,EmptyState,SkeletonCard}.vue`. Verify: `npm run type-check && npm run build`.
|
|
||||||
- **A3** Fix `.gitignore` lockfile lines (7: `Cargo.lock`, 15: `package-lock.json`) — lockfiles are intentionally tracked; the rules are misleading and swallow future lockfiles.
|
|
||||||
- **A4** LICENSE + Cargo license fields (see 4a). Verify with `cargo metadata`.
|
|
||||||
- **A5** `core/rust-toolchain.toml` pinning `1.95.0`; align `.github/workflows/ci.yml` (remove explicit `toolchain: stable` input so the file wins). Upgrades become deliberate PRs.
|
|
||||||
- **A6** `core/rustfmt.toml` codifying **defaults only** (`edition = "2021"` + comment) — do NOT add style options days before launch (whole-tree reformat churn). Verify `cargo fmt --all -- --check` yields no diff.
|
|
||||||
|
|
||||||
### B. CI guards (zero runtime risk)
|
|
||||||
- **B1** Enable vitest in CI: run `cd neode-ui && npm run test` locally; fix trivial failures, `.skip`+issue flaky ones; add step to the frontend job. Playwright → tracked issue only (needs browsers + mock backend orchestration).
|
|
||||||
- **B2** Raw podman/systemctl **ratchet, not migration**: the 132 raw `Command::new("podman"/"systemctl")` sites use subcommands the `core/container/src/podman_client.rs` wrapper doesn't expose (network/inspect/ps/port), 43 sites are in gate-critical `install.rs`, and the prod path intentionally uses Quadlet+systemctl. Add `scripts/ci/raw-podman-ratchet.sh` (count vs committed baseline, fail on increase) as a CI step + tracked issue for wrapper API design.
|
|
||||||
|
|
||||||
### C. Clippy suppression trim (`core/archipelago/src/main.rs:8-18`, per-lint commits)
|
|
||||||
- Remove cheaply: `assertions_on_constants`, `drop_non_drop`, `wildcard_in_or_patterns`, `doc_lazy_continuation`, `enum_variant_names` (targeted allows on serde enums — never rename wire variants).
|
|
||||||
- Own careful commit: `unused_io_amount` — a **correctness** lint; fix sites with `read_exact`/`write_all` or documented targeted allows (`mesh/serial.rs:456,496` has raw partial reads; serial framing may be intentional). Full test suite + gate after.
|
|
||||||
- Keep crate-wide with justifying comment: `too_many_arguments`, `type_complexity`; attempt `ptr_arg` (`&Vec<T>`→`&[T]`, mechanical) if time allows — first to cut.
|
|
||||||
- Verify each: `cargo clippy --all-targets --all-features -- -D warnings && cargo test --all-features`.
|
|
||||||
|
|
||||||
### D. dead_code lift — Tiers 1–2 pre-launch, Tier 3 → commented allows + issues
|
|
||||||
Per-module procedure (one file per commit): remove `#![allow(dead_code)]` → `cargo check
|
|
||||||
--all-targets --all-features` → triage each warning: (a) genuinely dead → delete;
|
|
||||||
(b) future-feature/protocol-mandated → targeted `#[allow(dead_code)] // TODO(#NNN): …`;
|
|
||||||
(c) missing wiring → keep + targeted allow + issue (don't fix wiring in this workstream) →
|
|
||||||
clippy `-D warnings` + tests → commit.
|
|
||||||
- **Tier 1 (small/leaf, S each):** `swarm/seed_advert.rs`, `transport/{mesh_transport,lan,chunking,delta}.rs`, `mesh/{crypto,alerts,types,outbox}.rs`, `streaming/mod.rs`, `wallet/mod.rs`.
|
|
||||||
- **Tier 2 (M each):** `fips/{mod,iface,dial}.rs` (41 external refs → little residual deadness), `mesh/{x3dh,ratchet,steganography,message_types}.rs` — for crypto files bias to (b) with roadmap comments (unused crypto attracts auditor noise; every kept item needs its why).
|
|
||||||
- **Tier 3 (defer, riskiest):** `mesh/{mod,reticulum,protocol,serial,bitcoin_relay}.rs`, `transport/mod.rs` — change each blanket allow to `#![allow(dead_code)] // Hardware-mesh surface partially wired; triage tracked in #NNN`.
|
|
||||||
- Optional S/M win: move `prod_orchestrator.rs`'s 5,034-line `#[cfg(test)]` module to a sibling file via `#[path]` (pure move, halves the 6,291-line file).
|
|
||||||
|
|
||||||
### E. stacks.rs legacy fallbacks (highest risk — LAST, evidence-gated)
|
|
||||||
Legacy installers for immich/btcpay/mempool/indeedhub (`core/archipelago/src/api/rpc/package/stacks.rs:838/1047/1267/1498`, ~1,000 LOC with hardcoded registry IPs) fire only on "unknown app_id, zero members installed", logging `INSTALL ORCH SKIP` (stacks.rs:673). Netbird already uses the hard-error replacement (stacks.rs:1898-1920).
|
|
||||||
1. Run the full gate on the node; grep install logs for `INSTALL ORCH SKIP`.
|
|
||||||
2. Zero SKIPs → replace each legacy body with the netbird-style hard error (keep orchestrator call + `adopt_stack_if_exists`; satisfies migrations-never-destroy-data). Re-run gate; any red → revert + issue.
|
|
||||||
3. Any SKIP → don't delete; issue: "deploy manifests fleet-wide, then delete legacy installers".
|
|
||||||
|
|
||||||
### Explicitly deferred → public tracked issues at launch
|
|
||||||
PodmanClient API extension + call-site migration; god-module splits (`install.rs`, `update.rs`, `mesh/mod.rs`); Playwright in CI; Tier-3 dead_code triage; `password123` default hardening.
|
|
||||||
|
|
||||||
## Phase 6 — Fresh-history publish
|
|
||||||
|
|
||||||
1. Freeze: all phases merged on internal `main`, gate green, catalog re-signed.
|
|
||||||
2. Build the public tree: `git archive`-style export of HEAD (never copy `.git/` — it holds
|
|
||||||
credentialed remotes) → new repo, single initial commit ("Initial public release, vX.Y.Z"),
|
|
||||||
optionally preserving CHANGELOG.md as the human-readable history.
|
|
||||||
3. Pre-publish gate on the export: `scripts/audit-secrets.sh` (fixed version) clean; grep-zero for
|
|
||||||
`sk-ant-`, rotated-password strings, `146.59.87.168`, tailnet `100.` IPs, `192.168.1.`,
|
|
||||||
internal hostnames; `du -sh .git` sanity (< ~100 MB); fresh `git clone` + `cd core && cargo build`
|
|
||||||
+ `cd neode-ui && npm ci && npm run build` on a clean machine/container; one app image pull
|
|
||||||
from the public domain.
|
|
||||||
4. Publish to GitHub; enable issue templates (already present in `.github/`); file the deferred-work
|
|
||||||
issues (from Phase 5's issue list) as the initial public issue set — honest and gives contributors entry points.
|
|
||||||
5. Internal repo remains the private full-history remote; decide sync direction post-launch
|
|
||||||
(recommend: public repo becomes canonical, private keeps only ops/infra notes).
|
|
||||||
|
|
||||||
## Verification (end-to-end)
|
|
||||||
|
|
||||||
- `tests/lifecycle/run-gate.sh` green on the node after Phases 3 + 5E (and after any lifecycle-touching commit).
|
|
||||||
- CI green on every phase commit: `cargo fmt --check`, `clippy -D warnings`, `cargo test --all-features`, frontend type-check + build + (new) vitest.
|
|
||||||
- Phase-6 clean-machine clone/build/pull test is the final acceptance test — it simulates the first outside developer.
|
|
||||||
- Docs acceptance: a reader following `docs/app-developer-guide.md` + the new quadlet/lifecycle/secrets docs can build and install an app manifest without any private infra.
|
|
||||||
|
|
||||||
## Sequencing / cut-line
|
|
||||||
|
|
||||||
Order: 0 → 1 → 2 → (3 ∥ 4) → 5 (A→E) → 6. Phases 0–2 are non-negotiable security; Phase 3 is the
|
|
||||||
functional blocker; Phase 4 is the developer-experience payload; Phase 5 can be cut after any
|
|
||||||
commit (minimum viable: A1–A6, B1–B2, unused_io_amount fix); Phase 6 last. If the timeline
|
|
||||||
compresses, Tier-2 dead_code and Phase E move to public issues — everything else holds.
|
|
||||||
@ -12,33 +12,6 @@ Full plan: `.claude/plans/enchanted-strolling-rocket.md`. Memory pointer:
|
|||||||
avoiding `mesh/listener/session.rs` transport plumbing + `mesh/mod.rs` routing, which this work
|
avoiding `mesh/listener/session.rs` transport plumbing + `mesh/mod.rs` routing, which this work
|
||||||
owns. Stay out of `meshtastic.rs`/`protocol.rs` to avoid collisions.
|
owns. Stay out of `meshtastic.rs`/`protocol.rs` to avoid collisions.
|
||||||
|
|
||||||
## Checkpoint 2026-07-28 — RNode connect + names FIXED, live-verified E2E (read this first)
|
|
||||||
|
|
||||||
The fleet reflash back to RNode firmware exposed a stack of bugs that made Reticulum
|
|
||||||
unusable on CP2102-bridged boards (Heltec V3 etc.) and left every archy node nameless on
|
|
||||||
RNS. All fixed in `a8c4694c` (backend) + `3f76b496` (UI), live-verified on archi-dev-box
|
|
||||||
and archy-x250-dev with a real RNode-to-RNode LXMF message (`transport: "reticulum"` in
|
|
||||||
mesh-messages) plus a cross-transport reply:
|
|
||||||
|
|
||||||
1. **probe_rnode boot race** — serial open pulses DTR/RTS via the USB-UART bridge → ESP32
|
|
||||||
power-cycles → KISS DETECT written 300ms later is eaten during ~2.5-3s of boot. Fix:
|
|
||||||
immediate probe (fast path) + drain-until-quiet boot settle + second DETECT window.
|
|
||||||
2. **configure() was a no-op on a running listener** (only enable/disable restarted it) —
|
|
||||||
the setup modal's apply/keep-as-is and every rename did nothing until process restart.
|
|
||||||
3. **Name propagation** — `config.advert_name` had no reader; `server.set-name` never
|
|
||||||
reached mesh; daemon display name fixed at spawn to the "Archy" default; the ARCHY:2
|
|
||||||
announce blob REPLACED the LXMF name. Now: announces carry msgpack
|
|
||||||
`[name, stamp_cost, sf, ARCHY-blob]` (Sideband-compatible, blob invisible to stock
|
|
||||||
clients), daemon has a `set_name` verb, renames bounce the session live.
|
|
||||||
4. **Daemon-death detection** (was invisible up to the 30-min RX-stall watchdog),
|
|
||||||
**modal re-trigger loop** (plugged_at used tty mtime → bumps on every open; now
|
|
||||||
btime/ctime), **ARCHY:2 federation-name clobber**, **mesh.refresh RPC** (Refresh button
|
|
||||||
now actually re-queries the radio), **Meshtastic mesh.broadcast now sends NodeInfo**.
|
|
||||||
|
|
||||||
Still open here: legacy-format peers (old fleet builds) show as `Reticulum <hex4>` until
|
|
||||||
they OTA; RNode RF params still daemon-hardcoded (EU-868 869.525/125k/SF8/CR5); Phase 4
|
|
||||||
multi-radio; duty-cycle guard.
|
|
||||||
|
|
||||||
## Status at a glance
|
## Status at a glance
|
||||||
|
|
||||||
| Phase | What | Status |
|
| Phase | What | Status |
|
||||||
|
|||||||
@ -146,7 +146,7 @@ app:
|
|||||||
path: /health
|
path: /health
|
||||||
interfaces:
|
interfaces:
|
||||||
main:
|
main:
|
||||||
type: ui
|
type: web
|
||||||
port: 8090
|
port: 8090
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -74,10 +74,9 @@ archy/
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 20+ and npm for frontend development.
|
- **macOS** (development machine): Node.js 20+, npm
|
||||||
- Rust stable for backend development.
|
- **Linux server** (`192.168.1.228`): Rust toolchain, Podman, Nginx, Debian 13
|
||||||
- Linux with Podman, systemd, and Nginx for host integration work.
|
- SSH key: `~/.ssh/archipelago-deploy`
|
||||||
- Debian 13 is the target runtime for release validation.
|
|
||||||
|
|
||||||
### Local Frontend Development
|
### Local Frontend Development
|
||||||
|
|
||||||
@ -87,18 +86,17 @@ npm install
|
|||||||
npm start # Vite dev server on :8100, mock backend on :5959
|
npm start # Vite dev server on :8100, mock backend on :5959
|
||||||
```
|
```
|
||||||
|
|
||||||
The dev server at `http://localhost:8100` uses a mock backend.
|
The dev server at `http://localhost:8100` uses a mock backend. Login with `password123`.
|
||||||
|
|
||||||
### Deploying Changes
|
### Deploying Changes
|
||||||
|
|
||||||
Release and host-integration builds should run on Linux. The deploy script rsyncs
|
**Never build Rust on macOS.** The deploy script rsyncs source to the Linux server and builds there.
|
||||||
source to a configured Linux target and builds there.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Deploy to the configured primary target (builds backend + frontend, restarts services)
|
# Deploy to live server (builds backend + frontend, restarts services)
|
||||||
./scripts/deploy-to-target.sh --live
|
./scripts/deploy-to-target.sh --live
|
||||||
|
|
||||||
# Deploy to both configured targets
|
# Deploy to both servers
|
||||||
./scripts/deploy-to-target.sh --both
|
./scripts/deploy-to-target.sh --both
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -116,16 +114,14 @@ The deploy script:
|
|||||||
# Frontend tests
|
# Frontend tests
|
||||||
cd neode-ui && npm test
|
cd neode-ui && npm test
|
||||||
|
|
||||||
# Backend tests
|
# Backend tests (on dev server via SSH)
|
||||||
cd core && cargo test --all-features
|
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||||
|
"cd ~/archy/core && cargo test --all-features"
|
||||||
|
|
||||||
# Both
|
# Both
|
||||||
./scripts/run-tests.sh
|
./scripts/run-tests.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
`scripts/run-tests.sh` can run backend tests on a Linux target when
|
|
||||||
`ARCHIPELAGO_SSH_HOST` and `ARCHIPELAGO_SSH_KEY` are set.
|
|
||||||
|
|
||||||
## Adding a New RPC Endpoint
|
## Adding a New RPC Endpoint
|
||||||
|
|
||||||
### 1. Create the Handler
|
### 1. Create the Handler
|
||||||
@ -204,7 +200,7 @@ async myAction(params: { name: string }): Promise<{ ok: boolean; result: string
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/deploy-to-target.sh --live
|
./scripts/deploy-to-target.sh --live
|
||||||
curl -X POST http://<node-host>/rpc/v1 \
|
curl -X POST http://192.168.1.228/rpc/v1 \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-b "archipelago_session=YOUR_SESSION" \
|
-b "archipelago_session=YOUR_SESSION" \
|
||||||
-d '{"method":"mymodule.action","params":{"name":"test"}}'
|
-d '{"method":"mymodule.action","params":{"name":"test"}}'
|
||||||
@ -313,5 +309,5 @@ mod tests {
|
|||||||
2. Make changes following the standards above
|
2. Make changes following the standards above
|
||||||
3. Test locally: `cd neode-ui && npm test`
|
3. Test locally: `cd neode-ui && npm test`
|
||||||
4. Deploy to dev server: `./scripts/deploy-to-target.sh --live`
|
4. Deploy to dev server: `./scripts/deploy-to-target.sh --live`
|
||||||
5. Verify on your configured development target
|
5. Verify at `http://192.168.1.228`
|
||||||
6. Commit with conventional format: `feat: add my feature`
|
6. Commit with conventional format: `feat: add my feature`
|
||||||
|
|||||||
@ -1,252 +0,0 @@
|
|||||||
# Nostr Git Source Hosting Plan
|
|
||||||
|
|
||||||
This plan describes how Archipelago can publish and accept contributions to its
|
|
||||||
source code through `ngit`, NIP-34, and GRASP while keeping the developer
|
|
||||||
experience inside Archipelago.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Publish Archipelago source from a sanitized, fresh-history repository.
|
|
||||||
- Make the in-app registry the primary onboarding path for contributors.
|
|
||||||
- Let contributors clone, branch, push PR branches, open PRs, and discuss issues
|
|
||||||
with a Nostr identity from their Archipelago node.
|
|
||||||
- Follow the Bitcoin Core development model: broad public review and easy forks,
|
|
||||||
with canonical merge authority held by a small maintainer set.
|
|
||||||
- Give contributors full read, fork, and proposal rights, but no direct merge
|
|
||||||
rights on the canonical repository.
|
|
||||||
- Keep the official maintainer identity and merge authority separate from user
|
|
||||||
node identities.
|
|
||||||
|
|
||||||
## Current Building Blocks
|
|
||||||
|
|
||||||
Archipelago already has most of the primitives needed for this:
|
|
||||||
|
|
||||||
- App manifests and the app registry already install developer tooling as
|
|
||||||
rootless Podman apps.
|
|
||||||
- The `gitea` app provides a conventional fallback Git UI and package registry.
|
|
||||||
- The app launcher already exposes a consent-gated NIP-07 bridge for launched
|
|
||||||
apps using `getPublicKey`, `signEvent`, NIP-04, and NIP-44 requests.
|
|
||||||
- The backend exposes node and identity Nostr signing RPC methods.
|
|
||||||
- FIPS gives nodes a stable mesh identity and private transport path, but repo
|
|
||||||
announcements and PRs should remain NIP-34 compatible on normal Nostr relays.
|
|
||||||
- DWN protocol registration exists and can be used later for local contribution
|
|
||||||
metadata/cache, but should not be required for the first public workflow.
|
|
||||||
|
|
||||||
## Protocol Basis
|
|
||||||
|
|
||||||
Use existing Nostr Git conventions rather than inventing an Archipelago-only
|
|
||||||
protocol:
|
|
||||||
|
|
||||||
- NIP-34 repository announcement events identify repositories with kind `30617`.
|
|
||||||
- NIP-34 repository state events publish branch/tag refs with kind `30618`.
|
|
||||||
- NIP-34 patches, pull requests, PR updates, issues, and status events use kinds
|
|
||||||
`1617`, `1618`, `1619`, `1621`, and `1630`-`1633`.
|
|
||||||
- `ngit` provides the `git-remote-nostr` helper for `nostr://` clone URLs and PR
|
|
||||||
branches.
|
|
||||||
- GRASP servers provide Git Smart HTTP storage while Nostr events remain the
|
|
||||||
authority for repository identity, refs, PRs, issues, and maintainer state.
|
|
||||||
|
|
||||||
Primary references:
|
|
||||||
|
|
||||||
- https://nips.nostr.com/34
|
|
||||||
- https://docs.rs/crate/ngit/latest/source/README.md
|
|
||||||
- https://ngit.dev/grasp/
|
|
||||||
|
|
||||||
## Recommended Architecture
|
|
||||||
|
|
||||||
### Apps
|
|
||||||
|
|
||||||
Create two first-party apps:
|
|
||||||
|
|
||||||
- `ngit`: CLI/runtime package containing `ngit` and `git-remote-nostr`.
|
|
||||||
- `archipelago-source`: web UI for cloning Archipelago source, viewing NIP-34
|
|
||||||
issues/PRs, opening branches, and submitting PR events.
|
|
||||||
|
|
||||||
The `archipelago-source` app should depend on `ngit`. It can also recommend
|
|
||||||
Gitea for users who want a conventional local web Git UI, but Gitea should not
|
|
||||||
be the source of truth for public contribution permissions.
|
|
||||||
|
|
||||||
### Contributor Onboarding
|
|
||||||
|
|
||||||
When the user installs `archipelago-source` from the registry:
|
|
||||||
|
|
||||||
1. Show a modal before first launch: "Contribute to Archipelago".
|
|
||||||
2. Explain that the app will use their Archipelago Nostr identity to clone and
|
|
||||||
sign contribution events.
|
|
||||||
3. Display the maintainer repository announcement, clone URL, maintainer npub,
|
|
||||||
and relay/GRASP endpoints.
|
|
||||||
4. Ask for consent to:
|
|
||||||
- fetch repository metadata from configured relays,
|
|
||||||
- clone source through `nostr://`,
|
|
||||||
- create local branches,
|
|
||||||
- sign NIP-34 issue/PR/comment events,
|
|
||||||
- push PR branches to approved GRASP servers.
|
|
||||||
5. Store approval per app origin, identity id, repository id, and relay set.
|
|
||||||
|
|
||||||
This should build on the existing NIP-07 app-launcher bridge, but use a more
|
|
||||||
specific permission scope than the generic sign-event approval.
|
|
||||||
|
|
||||||
### Identity And Permissions
|
|
||||||
|
|
||||||
Use four identity classes:
|
|
||||||
|
|
||||||
- `archipelago-maintainer`: an offline or tightly controlled Nostr key that
|
|
||||||
signs the canonical kind `30617` repo announcement and status/merge events.
|
|
||||||
- `archipelago-merge-maintainer`: one of the small set of maintainer npubs
|
|
||||||
allowed to advance canonical refs and publish valid merged/applied status.
|
|
||||||
- `archipelago-build`: release automation key for signed release artifacts and
|
|
||||||
CI status events. It must not have merge authority.
|
|
||||||
- `contributor`: user node or app-specific identity used for PRs, issues, and
|
|
||||||
comments.
|
|
||||||
|
|
||||||
Contributor rights:
|
|
||||||
|
|
||||||
- Clone the repository.
|
|
||||||
- Open issues.
|
|
||||||
- Push proposal branches using `pr/<npub>/<short-topic>` or `pr/<event-id>`.
|
|
||||||
- Publish NIP-34 PR/update/comment events.
|
|
||||||
- Rebase and update their own PR branch.
|
|
||||||
- Run local validation and attach status evidence.
|
|
||||||
|
|
||||||
Contributor restrictions:
|
|
||||||
|
|
||||||
- Cannot update `refs/heads/main` or release branches in canonical state.
|
|
||||||
- Cannot publish maintainer-valid merge/applied status.
|
|
||||||
- Cannot alter the canonical repository announcement.
|
|
||||||
- Cannot publish release catalog signatures.
|
|
||||||
|
|
||||||
Maintainer rights:
|
|
||||||
|
|
||||||
- Publish/update the canonical repo announcement.
|
|
||||||
- Publish canonical `refs/heads/main` state.
|
|
||||||
- Mark PRs merged/closed/draft via NIP-34 status events.
|
|
||||||
- Sign release tags and catalog updates.
|
|
||||||
|
|
||||||
Fork rights:
|
|
||||||
|
|
||||||
- Any contributor can create their own NIP-34 kind `30617` repository
|
|
||||||
announcement for a fork.
|
|
||||||
- Fork announcements should use the NIP-34 `u` tag to point back to the
|
|
||||||
canonical `archy` repository.
|
|
||||||
- The source app should make forking a first-class path: "Fork on Nostr", clone
|
|
||||||
the fork locally, push branches to the contributor's GRASP list, and open PRs
|
|
||||||
back to canonical Archipelago when they want review.
|
|
||||||
- Forks can have their own maintainer npubs, relays, policies, and release
|
|
||||||
cadence, but the app should clearly label them as forks unless signed by the
|
|
||||||
canonical maintainer set.
|
|
||||||
|
|
||||||
The GRASP server policy should enforce this by accepting pushes to maintainer
|
|
||||||
refs only when backed by signed maintainer state, while allowing contributor PR
|
|
||||||
refs from their own npubs.
|
|
||||||
|
|
||||||
## Repository Layout
|
|
||||||
|
|
||||||
Canonical repo announcement:
|
|
||||||
|
|
||||||
- repo id: `archy`
|
|
||||||
- display name: `Archipelago`
|
|
||||||
- clone URLs:
|
|
||||||
- `nostr://<maintainer-npub>/<relay-hint>/archy`
|
|
||||||
- `https://<grasp-host>/<maintainer-npub>/archy.git`
|
|
||||||
- relays:
|
|
||||||
- Archipelago-operated relay
|
|
||||||
- at least two public Nostr relays that support the event load
|
|
||||||
- GRASP servers:
|
|
||||||
- Archipelago-operated GRASP instance
|
|
||||||
- one public GRASP-compatible mirror
|
|
||||||
|
|
||||||
Keep the existing HTTP Git remote as a mirror during launch. The docs can
|
|
||||||
present `nostr://` as the preferred contribution path once the workflow is
|
|
||||||
proven.
|
|
||||||
|
|
||||||
## UI Requirements
|
|
||||||
|
|
||||||
The source app should provide:
|
|
||||||
|
|
||||||
- A first-run contribution modal with a real Archipelago source graphic, not a
|
|
||||||
generic text-only dialog.
|
|
||||||
- Current clone status and local path.
|
|
||||||
- Branch list, changed files, commit form, and push/open-PR flow.
|
|
||||||
- PR inbox, issue list, maintainer status, and relay health.
|
|
||||||
- Explicit identity indicator showing which npub will sign events.
|
|
||||||
- A merge rights indicator that clearly says contributors can propose changes
|
|
||||||
but cannot merge them.
|
|
||||||
- A fork flow that creates a user-owned NIP-34 repo announcement and remote,
|
|
||||||
then offers "Open PR to Archipelago" from any fork branch.
|
|
||||||
- Maintainer badges based only on pinned canonical maintainer npubs, not relay
|
|
||||||
metadata or server-side account names.
|
|
||||||
- Links to container docs, deployment docs, manifest spec, and open-source
|
|
||||||
readiness tasks.
|
|
||||||
|
|
||||||
## Backend Work
|
|
||||||
|
|
||||||
Add an RPC module for source contribution workflow:
|
|
||||||
|
|
||||||
- `source.repo-info`: returns canonical announcement, clone URL, relay set,
|
|
||||||
maintainer npubs, and local clone state.
|
|
||||||
- `source.ensure-ngit`: verifies the `ngit` app/runtime is installed.
|
|
||||||
- `source.clone`: clones or updates the local source checkout.
|
|
||||||
- `source.status`: returns branch, dirty files, ahead/behind, and PR state.
|
|
||||||
- `source.commit`: creates a local commit from selected files.
|
|
||||||
- `source.fork`: creates a contributor-owned NIP-34 fork announcement and local
|
|
||||||
remote.
|
|
||||||
- `source.open-pr`: pushes a PR branch and publishes a kind `1618` event.
|
|
||||||
- `source.update-pr`: updates the branch and publishes kind `1619`.
|
|
||||||
- `source.issue`: publishes a kind `1621` event.
|
|
||||||
|
|
||||||
Backend must shell out through a narrow command wrapper, never arbitrary user
|
|
||||||
commands. The wrapper should set an isolated working tree under
|
|
||||||
`/var/lib/archipelago/source/archy`, run as the Archipelago service user, and
|
|
||||||
deny operations outside that path.
|
|
||||||
|
|
||||||
## Security Model
|
|
||||||
|
|
||||||
- Never expose maintainer private keys to an Archipelago node.
|
|
||||||
- Prefer app-specific contributor identities over the node's default identity.
|
|
||||||
- Require per-action consent for first PR push, issue creation, and signing any
|
|
||||||
event that tags the canonical repository.
|
|
||||||
- Pin the canonical maintainer npub in the app manifest and backend config.
|
|
||||||
- Keep the canonical merge-maintainer allow list signed by the
|
|
||||||
`archipelago-maintainer` key; never infer merge rights from GRASP server
|
|
||||||
accounts.
|
|
||||||
- Verify the canonical kind `30617` event signature before displaying clone
|
|
||||||
instructions.
|
|
||||||
- Treat GRASP servers as untrusted storage; verify Git refs against signed
|
|
||||||
Nostr state.
|
|
||||||
- Do not use destructive git operations from the UI without an explicit modal.
|
|
||||||
- Store local clones and generated patches outside app container writable roots
|
|
||||||
unless the user exports them.
|
|
||||||
|
|
||||||
## MVP
|
|
||||||
|
|
||||||
1. Package `ngit` as a first-party app.
|
|
||||||
2. Stand up one Archipelago-operated GRASP server and one Nostr relay.
|
|
||||||
3. Publish sanitized fresh-history `archy` through `ngit init`.
|
|
||||||
4. Add a simple `archipelago-source` app that clones source and links out to the
|
|
||||||
preferred Nostr Git browser.
|
|
||||||
5. Add app-launcher consent scopes for repository-specific NIP-34 signing.
|
|
||||||
6. Allow issues and PR branch submission from contributor npubs.
|
|
||||||
7. Add a one-click fork flow that publishes a contributor-owned fork
|
|
||||||
announcement referencing canonical Archipelago.
|
|
||||||
8. Keep maintainer merge/status publication manual.
|
|
||||||
|
|
||||||
## Later
|
|
||||||
|
|
||||||
- Native PR review UI with file diffs and inline comments.
|
|
||||||
- CI status events signed by the build identity.
|
|
||||||
- FIPS-first source sync between trusted Archipelago nodes.
|
|
||||||
- Private prerelease repositories using NIP-42 allow lists and/or protected
|
|
||||||
events if the ecosystem support is mature enough.
|
|
||||||
- Multi-maintainer policy with threshold signatures or explicit maintainer-list
|
|
||||||
rotation events.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- Which maintainer npub should become canonical for `archy`?
|
|
||||||
- Should contributor identities be node-default or app-specific by default?
|
|
||||||
- Which GRASP implementation should be deployed first: `ngit-grasp` or another
|
|
||||||
NIP-34/GRASP-compatible relay?
|
|
||||||
- Should the source app include a full web Git UI in v1, or launch Gitea/ngit
|
|
||||||
browser links for review while keeping signing/submission native?
|
|
||||||
- What exact license and contribution certificate should contributors accept
|
|
||||||
before submitting PR events?
|
|
||||||
@ -2834,7 +2834,7 @@ After=network.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
Environment=ANTHROPIC_API_KEY=
|
Environment=ANTHROPIC_API_KEY=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
|
||||||
ExecStart=/usr/bin/python3 /opt/archipelago/claude-api-proxy.py
|
ExecStart=/usr/bin/python3 /opt/archipelago/claude-api-proxy.py
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
# Third-Party Licenses — neode-ui (web frontend)
|
|
||||||
|
|
||||||
Runtime dependencies bundled into the distributed web UI. Verified 2026-07-23.
|
|
||||||
Dev-only tooling (Vite, Playwright, TypeScript, etc.) is not distributed and
|
|
||||||
is not listed here. Note: the production bundler strips license header
|
|
||||||
comments, so this file (and the fonts' adjacent license files) constitute the
|
|
||||||
attribution shipped with the bundle; a build-time license-aggregation step is
|
|
||||||
planned (see docs/LICENSE-COMPLIANCE-AUDIT.md).
|
|
||||||
|
|
||||||
| Package | Version | License |
|
|
||||||
|---|---|---|
|
|
||||||
| vue | 3.5.x | MIT |
|
|
||||||
| vue-router | 4.6.x | MIT |
|
|
||||||
| vue-i18n | 11.3.x | MIT |
|
|
||||||
| pinia | 3.0.x | MIT |
|
|
||||||
| d3 | 7.9.x | ISC |
|
|
||||||
| leaflet | 1.9.x | BSD-2-Clause |
|
|
||||||
| @vue-leaflet/vue-leaflet | 0.10.x | MIT |
|
|
||||||
| dompurify | 3.4.x | MPL-2.0 OR Apache-2.0 (used under Apache-2.0) |
|
|
||||||
| buffer | 6.0.x | MIT |
|
|
||||||
| fast-json-patch | 3.1.x | MIT |
|
|
||||||
| fuse.js | 7.1.x | Apache-2.0 |
|
|
||||||
| qrcode | 1.5.x | MIT |
|
|
||||||
| qr-scanner | 1.4.x | MIT |
|
|
||||||
| qrloop | 1.4.x | MIT |
|
|
||||||
|
|
||||||
## Fonts
|
|
||||||
|
|
||||||
| Font | License | License file |
|
|
||||||
|---|---|---|
|
|
||||||
| Montserrat | SIL OFL 1.1 | public/assets/fonts/Montserrat/OFL.txt |
|
|
||||||
| Open Sans | Apache-2.0 | public/assets/fonts/Open_Sans/LICENSE.txt |
|
|
||||||
|
|
||||||
## Vendored
|
|
||||||
|
|
||||||
- `public/assets/icon/` — see ATTRIBUTION.md in that directory
|
|
||||||
(game-icons.net CC BY 3.0; pixelarticons MIT).
|
|
||||||
- `public/assets/img/mesh-devices/` — Meshtastic project artwork, GPL-3.0;
|
|
||||||
see ATTRIBUTION.md in that directory.
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
# Icon Attribution
|
|
||||||
|
|
||||||
- `barbarian.svg`, `batteries.svg` — from **game-icons.net**
|
|
||||||
(https://game-icons.net), by Lorc, Delapouite & contributors,
|
|
||||||
licensed under CC BY 3.0 (https://creativecommons.org/licenses/by/3.0/).
|
|
||||||
- Pixel-style icons (`save.svg`, `paint-bucket.svg`, `fill-half.svg`,
|
|
||||||
`cloud-moon.svg`, `debug-off.svg`, `cloud-done.svg`) — from
|
|
||||||
**pixelarticons** by Gerrit Halfmann (https://github.com/halfmage/pixelarticons),
|
|
||||||
MIT License.
|
|
||||||
- All other icons are original Archipelago artwork (MIT, see repository
|
|
||||||
LICENSE).
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
# Device Artwork Attribution
|
|
||||||
|
|
||||||
The device illustrations in this directory are from the
|
|
||||||
**Meshtastic® project** — https://meshtastic.org
|
|
||||||
(https://github.com/meshtastic), © Meshtastic contributors,
|
|
||||||
licensed under GPL-3.0.
|
|
||||||
|
|
||||||
Meshtastic® is a registered trademark of Meshtastic LLC, used here solely to
|
|
||||||
identify compatible hardware. No endorsement by the Meshtastic project is
|
|
||||||
implied.
|
|
||||||
|
|
||||||
Any modifications to these images made for Archipelago are likewise available
|
|
||||||
under GPL-3.0 as part of this public repository.
|
|
||||||
@ -4,16 +4,6 @@ export interface RPCOptions {
|
|||||||
method: string
|
method: string
|
||||||
params?: Record<string, unknown>
|
params?: Record<string, unknown>
|
||||||
timeout?: number
|
timeout?: number
|
||||||
/** Abort the call (and any pending retries) from the outside — pass a
|
|
||||||
* component-scoped controller's signal so fan-outs stop on unmount. */
|
|
||||||
signal?: AbortSignal
|
|
||||||
/** Per-call retry budget (default 3). Use 1 for calls whose caller has its
|
|
||||||
* own timeout/fallback UX — retry×3 on a slow peer is how one unreachable
|
|
||||||
* node turns a 30s timeout into a 90s spinner. */
|
|
||||||
maxRetries?: number
|
|
||||||
/** Collapse concurrent identical calls (same method + params) into one
|
|
||||||
* request. Opt-in: only safe for reads. */
|
|
||||||
dedup?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RPCResponse<T> {
|
export interface RPCResponse<T> {
|
||||||
@ -84,35 +74,18 @@ function getCsrfToken(): string | null {
|
|||||||
class RPCClient {
|
class RPCClient {
|
||||||
private static _sessionExpiredRedirecting = false
|
private static _sessionExpiredRedirecting = false
|
||||||
private baseUrl: string
|
private baseUrl: string
|
||||||
/** In-flight dedup map for `dedup: true` calls, keyed method+params. */
|
|
||||||
private inflight = new Map<string, Promise<unknown>>()
|
|
||||||
|
|
||||||
constructor(baseUrl: string = '/rpc/v1') {
|
constructor(baseUrl: string = '/rpc/v1') {
|
||||||
this.baseUrl = baseUrl
|
this.baseUrl = baseUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
async call<T>(options: RPCOptions): Promise<T> {
|
async call<T>(options: RPCOptions): Promise<T> {
|
||||||
if (options.dedup) {
|
const { method, params = {}, timeout = 15000 } = options
|
||||||
const key = `${options.method}:${JSON.stringify(options.params ?? {})}`
|
const maxRetries = 3
|
||||||
const existing = this.inflight.get(key)
|
|
||||||
if (existing) return existing as Promise<T>
|
|
||||||
const p = this.callInner<T>(options).finally(() => this.inflight.delete(key))
|
|
||||||
this.inflight.set(key, p)
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
return this.callInner<T>(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async callInner<T>(options: RPCOptions): Promise<T> {
|
|
||||||
const { method, params = {}, timeout = 15000, signal: external } = options
|
|
||||||
const maxRetries = Math.max(1, options.maxRetries ?? 3)
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
if (external?.aborted) throw new Error('Aborted')
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||||
const onExternalAbort = () => controller.abort()
|
|
||||||
external?.addEventListener('abort', onExternalAbort, { once: true })
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@ -132,7 +105,6 @@ class RPCClient {
|
|||||||
})
|
})
|
||||||
|
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
external?.removeEventListener('abort', onExternalAbort)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Session expired — debounced redirect to login
|
// Session expired — debounced redirect to login
|
||||||
@ -195,11 +167,8 @@ class RPCClient {
|
|||||||
return data.result as T
|
return data.result as T
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
external?.removeEventListener('abort', onExternalAbort)
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
// Caller-initiated abort is final — never retried.
|
|
||||||
if (external?.aborted) throw new Error('Aborted')
|
|
||||||
const timeoutErr = new Error('Request timeout')
|
const timeoutErr = new Error('Request timeout')
|
||||||
if (attempt < maxRetries - 1) {
|
if (attempt < maxRetries - 1) {
|
||||||
const delay = 600 * (attempt + 1)
|
const delay = 600 * (attempt + 1)
|
||||||
|
|||||||
@ -377,9 +377,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useCachedResource } from '@/composables/useCachedResource'
|
|
||||||
import { useTxExplorer } from '@/composables/useTxExplorer'
|
import { useTxExplorer } from '@/composables/useTxExplorer'
|
||||||
|
|
||||||
defineProps<{ compact?: boolean }>()
|
defineProps<{ compact?: boolean }>()
|
||||||
@ -463,41 +462,11 @@ const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: n
|
|||||||
{ key: 'custom', label: 'Custom' },
|
{ key: 'custom', label: 'Custom' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Cached: revisits paint the channel lists instantly and revalidate behind
|
const loading = ref(true)
|
||||||
// them. Open and closed history are separate entries so a closed-history
|
const error = ref<string | null>(null)
|
||||||
// failure keeps its last list without touching the main channel view.
|
const channels = ref<Channel[]>([])
|
||||||
interface ChannelsData { channels: Channel[]; total_inbound: number; total_outbound: number }
|
const closedChannels = ref<ClosedChannel[]>([])
|
||||||
const channelsRes = useCachedResource<ChannelsData>({
|
const summary = ref({ total_inbound: 0, total_outbound: 0 })
|
||||||
key: 'lnd.channels',
|
|
||||||
fetcher: async (signal) => {
|
|
||||||
const result = await rpcClient.call<ChannelsData>({
|
|
||||||
method: 'lnd.listchannels', timeout: 15000, signal, dedup: true, maxRetries: 1,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
channels: result?.channels || [],
|
|
||||||
total_inbound: result?.total_inbound || 0,
|
|
||||||
total_outbound: result?.total_outbound || 0,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const closedRes = useCachedResource<ClosedChannel[]>({
|
|
||||||
key: 'lnd.closed-channels',
|
|
||||||
fetcher: async (signal) => {
|
|
||||||
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
|
|
||||||
method: 'lnd.closedchannels', timeout: 15000, signal, dedup: true, maxRetries: 1,
|
|
||||||
})
|
|
||||||
return closed?.channels || []
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const loading = computed(() =>
|
|
||||||
channelsRes.loadState.value === 'loading' || channelsRes.loadState.value === 'refreshing')
|
|
||||||
const error = computed(() => channelsRes.error.value)
|
|
||||||
const channels = computed(() => channelsRes.data.value?.channels ?? [])
|
|
||||||
const closedChannels = computed(() => closedRes.data.value ?? [])
|
|
||||||
const summary = computed(() => ({
|
|
||||||
total_inbound: channelsRes.data.value?.total_inbound ?? 0,
|
|
||||||
total_outbound: channelsRes.data.value?.total_outbound ?? 0,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Olympus by ZEUS — the LSP node behind the Zeus mobile wallet.
|
// Olympus by ZEUS — the LSP node behind the Zeus mobile wallet.
|
||||||
// Channel limits: min 150,000 / max 1,500,000 sats.
|
// Channel limits: min 150,000 / max 1,500,000 sats.
|
||||||
@ -569,10 +538,37 @@ function capacityPercent(amount: number, capacity: number): number {
|
|||||||
return Math.round((amount / capacity) * 100)
|
return Math.round((amount / capacity) * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadChannels(): Promise<void> {
|
async function loadChannels() {
|
||||||
const main = channelsRes.refresh()
|
const hadChannels = channels.value.length > 0
|
||||||
void closedRes.refresh()
|
loading.value = true
|
||||||
return main
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await rpcClient.call<{ channels: Channel[]; total_inbound: number; total_outbound: number }>({
|
||||||
|
method: 'lnd.listchannels',
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
channels.value = result.channels || []
|
||||||
|
summary.value = {
|
||||||
|
total_inbound: result.total_inbound || 0,
|
||||||
|
total_outbound: result.total_outbound || 0,
|
||||||
|
}
|
||||||
|
// Closed history is a separate RPC — a failure here keeps the previous
|
||||||
|
// list rather than blanking the main channel view.
|
||||||
|
try {
|
||||||
|
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
|
||||||
|
method: 'lnd.closedchannels',
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
closedChannels.value = closed.channels || []
|
||||||
|
} catch {
|
||||||
|
/* keep previous closed list */
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to load channels'
|
||||||
|
if (!hadChannels) channels.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
|
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
|
||||||
@ -651,5 +647,7 @@ async function closeChannel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(loadChannels)
|
||||||
|
|
||||||
defineExpose({ channels, loadChannels })
|
defineExpose({ channels, loadChannels })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseModal
|
<BaseModal
|
||||||
:show="show"
|
:show="show"
|
||||||
:title="step === 1 ? 'Mesh Radio Detected' : 'Set Recommended'"
|
:title="step === 1 ? 'Mesh Radio Detected' : 'Apply Archipelago Settings'"
|
||||||
max-width="max-w-lg"
|
max-width="max-w-lg"
|
||||||
content-class="max-h-[90vh] overflow-y-auto"
|
content-class="max-h-[90vh] overflow-y-auto"
|
||||||
@close="dismiss"
|
@close="dismiss"
|
||||||
@ -35,17 +35,9 @@
|
|||||||
|
|
||||||
<!-- What's currently flashed / configured on it -->
|
<!-- What's currently flashed / configured on it -->
|
||||||
<div class="mt-4 text-left rounded-xl bg-white/[0.05] border border-white/10 p-3">
|
<div class="mt-4 text-left rounded-xl bg-white/[0.05] border border-white/10 p-3">
|
||||||
<div v-if="probing" class="py-1">
|
<div v-if="probing" class="flex items-center gap-2 text-white/60 text-sm py-1">
|
||||||
<div class="flex items-center justify-between text-white/60 text-sm mb-1.5">
|
<span class="inline-block w-3.5 h-3.5 rounded-full border-2 border-orange-300/70 border-t-transparent animate-spin"></span>
|
||||||
<span>{{ probeStage }}</span>
|
Reading what's on the radio…
|
||||||
<span class="text-white/40 text-xs tabular-nums">{{ Math.round(probeProgress) }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="h-1.5 rounded-full bg-white/10 overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full bg-orange-400/80 transition-[width] duration-500 ease-linear"
|
|
||||||
:style="{ width: probeProgress + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<template v-else-if="probe">
|
<template v-else-if="probe">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@ -102,7 +94,7 @@
|
|||||||
:disabled="!!connecting"
|
:disabled="!!connecting"
|
||||||
@click="step = 2"
|
@click="step = 2"
|
||||||
>
|
>
|
||||||
Set Recommended
|
Set Up with Archipelago Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-white/40 text-[11px] mt-3">
|
<p class="text-white/40 text-[11px] mt-3">
|
||||||
@ -115,8 +107,8 @@
|
|||||||
<!-- Step 2: our latest parameters, shown before anything is written -->
|
<!-- Step 2: our latest parameters, shown before anything is written -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p class="text-white/60 text-xs mb-3">
|
<p class="text-white/60 text-xs mb-3">
|
||||||
These are the recommended Archipelago settings — nothing is written to
|
These are the latest Archipelago settings — nothing is written to the
|
||||||
the radio until you confirm.
|
radio until you confirm.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Summary of what will be applied -->
|
<!-- Summary of what will be applied -->
|
||||||
@ -214,30 +206,6 @@ const probing = ref(false)
|
|||||||
const probe = ref<MeshDeviceProbe | null>(null)
|
const probe = ref<MeshDeviceProbe | null>(null)
|
||||||
const probeError = ref('')
|
const probeError = ref('')
|
||||||
|
|
||||||
// Time-driven probe progress: the probe RPC is a single opaque call that can
|
|
||||||
// take ~5-30s (boot settle + up to three firmware handshakes), so the bar
|
|
||||||
// advances on a clock toward 92% and snaps to 100% when the result lands.
|
|
||||||
const probeProgress = ref(0)
|
|
||||||
const probeStage = ref('Waiting for the radio to boot…')
|
|
||||||
let probeTicker: ReturnType<typeof setInterval> | null = null
|
|
||||||
function startProbeProgress() {
|
|
||||||
stopProbeProgress()
|
|
||||||
probeProgress.value = 0
|
|
||||||
probeStage.value = 'Waiting for the radio to boot…'
|
|
||||||
const startedAt = Date.now()
|
|
||||||
probeTicker = setInterval(() => {
|
|
||||||
const elapsed = (Date.now() - startedAt) / 1000
|
|
||||||
// ~92% at 30s, decelerating — never looks stuck, never lies "done".
|
|
||||||
probeProgress.value = Math.min(92, 100 * (1 - Math.exp(-elapsed / 11)))
|
|
||||||
if (elapsed >= 4) probeStage.value = 'Detecting firmware…'
|
|
||||||
if (elapsed >= 18) probeStage.value = 'Still checking (radios can be slow to answer)…'
|
|
||||||
}, 400)
|
|
||||||
}
|
|
||||||
function stopProbeProgress(done = false) {
|
|
||||||
if (probeTicker) { clearInterval(probeTicker); probeTicker = null }
|
|
||||||
if (done) probeProgress.value = 100
|
|
||||||
}
|
|
||||||
|
|
||||||
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
|
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
|
||||||
const show = computed(() => !!devicePath.value)
|
const show = computed(() => !!devicePath.value)
|
||||||
const imageFailed = ref(false)
|
const imageFailed = ref(false)
|
||||||
@ -307,7 +275,6 @@ watch([show, devicePath], async ([visible]) => {
|
|||||||
probe.value = null
|
probe.value = null
|
||||||
probeError.value = ''
|
probeError.value = ''
|
||||||
probing.value = true
|
probing.value = true
|
||||||
startProbeProgress()
|
|
||||||
const path = devicePath.value
|
const path = devicePath.value
|
||||||
try {
|
try {
|
||||||
const res = await mesh.probeDevice(path)
|
const res = await mesh.probeDevice(path)
|
||||||
@ -317,7 +284,6 @@ watch([show, devicePath], async ([visible]) => {
|
|||||||
probeError.value = e instanceof Error ? e.message : String(e)
|
probeError.value = e instanceof Error ? e.message : String(e)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
stopProbeProgress(true)
|
|
||||||
if (devicePath.value === path) probing.value = false
|
if (devicePath.value === path) probing.value = false
|
||||||
}
|
}
|
||||||
}, { immediate: false })
|
}, { immediate: false })
|
||||||
|
|||||||
@ -1,107 +0,0 @@
|
|||||||
// Stale-while-revalidate resource hook over the shared resources store.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// const files = useCachedResource<CloudFile[]>({
|
|
||||||
// key: 'cloud.my-files',
|
|
||||||
// fetcher: (signal) => rpcClient.call({ method: 'content.list', signal, dedup: true }),
|
|
||||||
// ttlMs: 30_000,
|
|
||||||
// })
|
|
||||||
// // template: files.data renders instantly on revisit (cache), while
|
|
||||||
// // files.loadState === 'refreshing' drives a subtle refresh indicator.
|
|
||||||
//
|
|
||||||
// Behavior:
|
|
||||||
// - Synchronous hydrate: memory (survives navigation) → sessionStorage
|
|
||||||
// snapshot (survives reload) → fetch.
|
|
||||||
// - Sticky-ready: never regresses ready → loading; refreshes are
|
|
||||||
// 'refreshing' so content stays on screen.
|
|
||||||
// - Stale-while-revalidate: on mount, cached data is shown immediately and a
|
|
||||||
// background refresh runs only if the TTL has lapsed (or never fetched).
|
|
||||||
// - Keep-last-value on error, with `isStale`/`ageMs` for badges.
|
|
||||||
// - revalidateOnFocus: refreshes when the tab regains focus and the data is
|
|
||||||
// stale (debounced by TTL, so focus-flapping is free).
|
|
||||||
// - Abort-on-unmount: the fetcher receives an AbortSignal that fires when
|
|
||||||
// the last subscribed component unmounts.
|
|
||||||
|
|
||||||
import { computed, getCurrentScope, onScopeDispose, type ComputedRef } from 'vue'
|
|
||||||
import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources'
|
|
||||||
|
|
||||||
export interface CachedResourceOptions<T> {
|
|
||||||
/** Cache key. Include identifying params, e.g. `peer-files:${onion}`. */
|
|
||||||
key: string
|
|
||||||
/** Fetch fresh data. Receives an abort signal tied to component lifetime. */
|
|
||||||
fetcher: (signal: AbortSignal) => Promise<T>
|
|
||||||
/** Data older than this triggers a background revalidate (default 30s). */
|
|
||||||
ttlMs?: number
|
|
||||||
/** Snapshot to sessionStorage so reloads paint instantly (default true).
|
|
||||||
* Disable for large payloads. */
|
|
||||||
persist?: boolean
|
|
||||||
/** Revalidate (if stale) when the window regains focus (default true). */
|
|
||||||
revalidateOnFocus?: boolean
|
|
||||||
/** Fetch on first use (default true). Set false for lazy resources. */
|
|
||||||
immediate?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CachedResource<T> {
|
|
||||||
entry: ResourceEntry<T>
|
|
||||||
/** Convenience computed views over the entry. */
|
|
||||||
data: ComputedRef<T | null>
|
|
||||||
loadState: ComputedRef<ResourceLoadState>
|
|
||||||
error: ComputedRef<string | null>
|
|
||||||
/** True when data exists but is older than the TTL (drive an age badge). */
|
|
||||||
isStale: ComputedRef<boolean>
|
|
||||||
ageMs: ComputedRef<number | null>
|
|
||||||
/** Force a refresh now (deduped with any in-flight one). */
|
|
||||||
refresh: () => Promise<void>
|
|
||||||
/** Mark stale + debounce-refresh all mounted users of this key. */
|
|
||||||
invalidate: () => void
|
|
||||||
/** Optimistically update cached data; returns rollback for RPC failure. */
|
|
||||||
optimistic: (update: (current: T | null) => T) => () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCachedResource<T>(opts: CachedResourceOptions<T>): CachedResource<T> {
|
|
||||||
const store = useResourcesStore()
|
|
||||||
const ttlMs = opts.ttlMs ?? 30_000
|
|
||||||
const persist = opts.persist ?? true
|
|
||||||
const entry = store.entry<T>(opts.key, persist)
|
|
||||||
|
|
||||||
const aborter = new AbortController()
|
|
||||||
const fetcher = () => opts.fetcher(aborter.signal)
|
|
||||||
const refresh = () => store.refresh(opts.key, fetcher, { persist })
|
|
||||||
|
|
||||||
const stale = () => entry.fetchedAt === null || Date.now() - entry.fetchedAt > ttlMs
|
|
||||||
const refreshIfStale = () => {
|
|
||||||
if (stale()) void refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register as a live revalidator so invalidate(key) reaches us.
|
|
||||||
const unsubscribe = store.subscribe(opts.key, () => void refresh())
|
|
||||||
|
|
||||||
const onFocus = () => refreshIfStale()
|
|
||||||
if (opts.revalidateOnFocus ?? true) {
|
|
||||||
window.addEventListener('focus', onFocus)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tied to the owning effect scope (component setup or manual scope);
|
|
||||||
// outside any scope (tests, module init) there's nothing to dispose.
|
|
||||||
if (getCurrentScope()) {
|
|
||||||
onScopeDispose(() => {
|
|
||||||
unsubscribe()
|
|
||||||
window.removeEventListener('focus', onFocus)
|
|
||||||
aborter.abort()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.immediate ?? true) refreshIfStale()
|
|
||||||
|
|
||||||
return {
|
|
||||||
entry,
|
|
||||||
data: computed(() => entry.data),
|
|
||||||
loadState: computed(() => entry.loadState),
|
|
||||||
error: computed(() => entry.error),
|
|
||||||
isStale: computed(() => entry.data !== null && stale()),
|
|
||||||
ageMs: computed(() => (entry.fetchedAt === null ? null : Date.now() - entry.fetchedAt)),
|
|
||||||
refresh,
|
|
||||||
invalidate: () => store.invalidate(opts.key),
|
|
||||||
optimistic: (update) => store.optimistic<T>(opts.key, update),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
||||||
import { setActivePinia, createPinia } from 'pinia'
|
|
||||||
|
|
||||||
import { useResourcesStore } from '../resources'
|
|
||||||
import { useCachedResource } from '@/composables/useCachedResource'
|
|
||||||
|
|
||||||
describe('resources store — stale-while-revalidate semantics', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
setActivePinia(createPinia())
|
|
||||||
sessionStorage.clear()
|
|
||||||
vi.useFakeTimers()
|
|
||||||
})
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('first fetch goes idle → loading → ready with data', async () => {
|
|
||||||
const store = useResourcesStore()
|
|
||||||
const e = store.entry<string>('k1')
|
|
||||||
expect(e.loadState).toBe('idle')
|
|
||||||
const p = store.refresh('k1', async () => 'hello')
|
|
||||||
expect(e.loadState).toBe('loading')
|
|
||||||
await p
|
|
||||||
expect(e.loadState).toBe('ready')
|
|
||||||
expect(e.data).toBe('hello')
|
|
||||||
expect(e.fetchedAt).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sticky-ready: refresh never regresses ready → loading', async () => {
|
|
||||||
const store = useResourcesStore()
|
|
||||||
await store.refresh('k2', async () => 1)
|
|
||||||
const e = store.entry<number>('k2')
|
|
||||||
const p = store.refresh('k2', async () => 2)
|
|
||||||
expect(e.loadState).toBe('refreshing')
|
|
||||||
await p
|
|
||||||
expect(e.loadState).toBe('ready')
|
|
||||||
expect(e.data).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps last-known data on refresh error (ready + error set)', async () => {
|
|
||||||
const store = useResourcesStore()
|
|
||||||
await store.refresh('k3', async () => 'good')
|
|
||||||
const e = store.entry<string>('k3')
|
|
||||||
await store.refresh('k3', async () => {
|
|
||||||
throw new Error('boom')
|
|
||||||
})
|
|
||||||
expect(e.data).toBe('good')
|
|
||||||
expect(e.loadState).toBe('ready')
|
|
||||||
expect(e.error).toBe('boom')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('errors with no prior data land in error state', async () => {
|
|
||||||
const store = useResourcesStore()
|
|
||||||
await store.refresh('k4', async () => {
|
|
||||||
throw new Error('down')
|
|
||||||
})
|
|
||||||
const e = store.entry('k4')
|
|
||||||
expect(e.loadState).toBe('error')
|
|
||||||
expect(e.data).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('dedups concurrent refreshes for the same key', async () => {
|
|
||||||
const store = useResourcesStore()
|
|
||||||
const fetcher = vi.fn(async () => 'once')
|
|
||||||
const p1 = store.refresh('k5', fetcher)
|
|
||||||
const p2 = store.refresh('k5', fetcher)
|
|
||||||
await Promise.all([p1, p2])
|
|
||||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hydrates a new entry from the sessionStorage snapshot', async () => {
|
|
||||||
const store = useResourcesStore()
|
|
||||||
await store.refresh('k6', async () => ({ n: 42 }))
|
|
||||||
// Fresh pinia = fresh memory cache, same sessionStorage.
|
|
||||||
setActivePinia(createPinia())
|
|
||||||
const store2 = useResourcesStore()
|
|
||||||
const e = store2.entry<{ n: number }>('k6')
|
|
||||||
expect(e.loadState).toBe('ready')
|
|
||||||
expect(e.data).toEqual({ n: 42 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('optimistic update applies immediately and rollback restores', async () => {
|
|
||||||
const store = useResourcesStore()
|
|
||||||
await store.refresh('k7', async () => ['a'])
|
|
||||||
const e = store.entry<string[]>('k7')
|
|
||||||
const rollback = store.optimistic<string[]>('k7', (cur) => [...(cur ?? []), 'b'])
|
|
||||||
expect(e.data).toEqual(['a', 'b'])
|
|
||||||
rollback()
|
|
||||||
expect(e.data).toEqual(['a'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('invalidate marks stale and debounce-runs subscribers', async () => {
|
|
||||||
const store = useResourcesStore()
|
|
||||||
await store.refresh('k8', async () => 1)
|
|
||||||
const revalidate = vi.fn()
|
|
||||||
store.subscribe('k8', revalidate)
|
|
||||||
store.invalidate('k8')
|
|
||||||
expect(store.entry('k8').fetchedAt).toBeNull()
|
|
||||||
expect(revalidate).not.toHaveBeenCalled()
|
|
||||||
vi.advanceTimersByTime(900)
|
|
||||||
expect(revalidate).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('useCachedResource composable', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
setActivePinia(createPinia())
|
|
||||||
sessionStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('fetches immediately when stale and exposes reactive views', async () => {
|
|
||||||
const fetcher = vi.fn(async () => 'data')
|
|
||||||
const r = useCachedResource<string>({ key: 'c1', fetcher, revalidateOnFocus: false })
|
|
||||||
await r.refresh()
|
|
||||||
expect(fetcher).toHaveBeenCalled()
|
|
||||||
expect(r.data.value).toBe('data')
|
|
||||||
expect(r.loadState.value).toBe('ready')
|
|
||||||
expect(r.isStale.value).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not refetch within TTL (instant render from cache)', async () => {
|
|
||||||
const fetcher = vi.fn(async () => 'v1')
|
|
||||||
const r1 = useCachedResource<string>({ key: 'c2', fetcher, ttlMs: 60_000, revalidateOnFocus: false })
|
|
||||||
await r1.refresh()
|
|
||||||
// Second component using the same key inside the TTL: no new fetch.
|
|
||||||
const fetcher2 = vi.fn(async () => 'v2')
|
|
||||||
const r2 = useCachedResource<string>({ key: 'c2', fetcher: fetcher2, ttlMs: 60_000, revalidateOnFocus: false })
|
|
||||||
expect(r2.data.value).toBe('v1')
|
|
||||||
expect(fetcher2).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -8,11 +8,6 @@ export const useCloudStore = defineStore('cloud', () => {
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const authenticated = ref(false)
|
const authenticated = ref(false)
|
||||||
// Per-path listing cache: re-entering a folder paints the last listing
|
|
||||||
// immediately (no spinner) while the fresh listing loads behind it.
|
|
||||||
const pathCache = new Map<string, FileBrowserItem[]>()
|
|
||||||
// Last-wins guard for overlapping navigations (fast folder hopping).
|
|
||||||
let navSeq = 0
|
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
const parts = currentPath.value.split('/').filter(Boolean)
|
const parts = currentPath.value.split('/').filter(Boolean)
|
||||||
@ -41,22 +36,7 @@ export const useCloudStore = defineStore('cloud', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function navigate(path: string): Promise<void> {
|
async function navigate(path: string): Promise<void> {
|
||||||
const seq = ++navSeq
|
loading.value = true
|
||||||
const apply = (p: string, result: FileBrowserItem[]) => {
|
|
||||||
pathCache.set(p, result)
|
|
||||||
if (seq !== navSeq) return // a newer navigation superseded this one
|
|
||||||
items.value = result
|
|
||||||
currentPath.value = p
|
|
||||||
}
|
|
||||||
// Stale-while-revalidate: show the cached listing for this path
|
|
||||||
// immediately (no spinner), then refresh it underneath.
|
|
||||||
const cached = pathCache.get(path)
|
|
||||||
if (cached) {
|
|
||||||
items.value = cached
|
|
||||||
currentPath.value = path
|
|
||||||
} else {
|
|
||||||
loading.value = true
|
|
||||||
}
|
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
if (!authenticated.value) {
|
if (!authenticated.value) {
|
||||||
@ -67,7 +47,9 @@ export const useCloudStore = defineStore('cloud', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
apply(path, await fileBrowserClient.listDirectory(path))
|
const result = await fileBrowserClient.listDirectory(path)
|
||||||
|
items.value = result
|
||||||
|
currentPath.value = path
|
||||||
} catch {
|
} catch {
|
||||||
// Directory may not exist — try to create it, then retry
|
// Directory may not exist — try to create it, then retry
|
||||||
if (path !== '/') {
|
if (path !== '/') {
|
||||||
@ -75,20 +57,23 @@ export const useCloudStore = defineStore('cloud', () => {
|
|||||||
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
|
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
|
||||||
const dirName = path.substring(path.lastIndexOf('/') + 1)
|
const dirName = path.substring(path.lastIndexOf('/') + 1)
|
||||||
await fileBrowserClient.createFolder(parentPath, dirName)
|
await fileBrowserClient.createFolder(parentPath, dirName)
|
||||||
apply(path, await fileBrowserClient.listDirectory(path))
|
const result = await fileBrowserClient.listDirectory(path)
|
||||||
|
items.value = result
|
||||||
|
currentPath.value = path
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back to root
|
// Fall back to root
|
||||||
apply('/', await fileBrowserClient.listDirectory('/'))
|
const result = await fileBrowserClient.listDirectory('/')
|
||||||
|
items.value = result
|
||||||
|
currentPath.value = '/'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Failed to list root directory')
|
throw new Error('Failed to list root directory')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Keep showing the cached listing on a failed revalidate.
|
error.value = e instanceof Error ? e.message : 'Failed to load files'
|
||||||
if (!cached) error.value = e instanceof Error ? e.message : 'Failed to load files'
|
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === navSeq) loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,7 +112,6 @@ export const useCloudStore = defineStore('cloud', () => {
|
|||||||
items.value = []
|
items.value = []
|
||||||
loading.value = false
|
loading.value = false
|
||||||
error.value = null
|
error.value = null
|
||||||
pathCache.clear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -264,14 +264,8 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
|
|
||||||
// Track unread message counts per peer (contact_id -> count)
|
// Track unread message counts per peer (contact_id -> count)
|
||||||
const unreadCounts = ref<Record<number, number>>({})
|
const unreadCounts = ref<Record<number, number>>({})
|
||||||
// Contact ids of the chat currently on screen — ALL twins of the merged
|
// Currently viewing chat for this contact_id (clears unread)
|
||||||
// conversation, not just the clicked row's id, since the unread badge sums
|
const viewingChatId = ref<number | null>(null)
|
||||||
// across every underlying contact_id (a message can land on any twin).
|
|
||||||
const viewingChatIds = ref<number[]>([])
|
|
||||||
// Whether the open chat is scrolled to (near) the bottom. New messages only
|
|
||||||
// auto-clear as read while the latest messages are actually in view —
|
|
||||||
// standard chat-app semantics; scrolled-up-in-history still counts unread.
|
|
||||||
const viewingAtBottom = ref(true)
|
|
||||||
// Total unread count for nav badge
|
// Total unread count for nav badge
|
||||||
const totalUnread = computed(() =>
|
const totalUnread = computed(() =>
|
||||||
Object.values(unreadCounts.value).reduce((a, b) => a + b, 0)
|
Object.values(unreadCounts.value).reduce((a, b) => a + b, 0)
|
||||||
@ -280,16 +274,12 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
async function fetchStatus() {
|
async function fetchStatus() {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
|
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
|
||||||
status.value = res
|
status.value = res
|
||||||
trackDetectedDevices(res)
|
trackDetectedDevices(res)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Don't clobber a user-action error (broadcast/configure/send) — this
|
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
|
||||||
// runs on a 5s poll, and the old `error.value = null` on entry meant
|
|
||||||
// any real error banner survived at most one poll tick.
|
|
||||||
if (!error.value) {
|
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -407,11 +397,8 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
m => m.direction === 'received' && !messages.value.some(existing => existing.id === m.id)
|
m => m.direction === 'received' && !messages.value.some(existing => existing.id === m.id)
|
||||||
)
|
)
|
||||||
for (const msg of newMsgs) {
|
for (const msg of newMsgs) {
|
||||||
// Don't count as unread if we're currently viewing that chat AND the
|
// Don't count as unread if we're currently viewing that chat
|
||||||
// bottom (latest messages) is in view — i.e. the user actually sees it.
|
if (msg.peer_contact_id !== viewingChatId.value) {
|
||||||
const seenLive =
|
|
||||||
viewingChatIds.value.includes(msg.peer_contact_id) && viewingAtBottom.value
|
|
||||||
if (!seenLive) {
|
|
||||||
unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1
|
unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -539,15 +526,13 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
federatedPositions.value = next
|
federatedPositions.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
function markChatRead(contactId: number | number[]) {
|
function markChatRead(contactId: number) {
|
||||||
const ids = Array.isArray(contactId) ? contactId : [contactId]
|
viewingChatId.value = contactId
|
||||||
viewingChatIds.value = ids
|
delete unreadCounts.value[contactId]
|
||||||
for (const id of ids) delete unreadCounts.value[id]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearViewingChat() {
|
function clearViewingChat() {
|
||||||
viewingChatIds.value = []
|
viewingChatId.value = null
|
||||||
viewingAtBottom.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage(contactId: number, message: string) {
|
async function sendMessage(contactId: number, message: string) {
|
||||||
@ -692,8 +677,6 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
tier: 'auto-mesh' | 'choose' | 'resource-mesh' | 'tor-only' | 'impossible'
|
tier: 'auto-mesh' | 'choose' | 'resource-mesh' | 'tor-only' | 'impossible'
|
||||||
est_seconds: number
|
est_seconds: number
|
||||||
has_tor: boolean
|
has_tor: boolean
|
||||||
has_fips: boolean
|
|
||||||
last_transport: string | null
|
|
||||||
reason: string
|
reason: string
|
||||||
size: number
|
size: number
|
||||||
mesh_auto_max: number
|
mesh_auto_max: number
|
||||||
@ -992,18 +975,6 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()])
|
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ask the backend to actively re-query the radio's contact table (and by
|
|
||||||
* extension re-drain daemon events for Reticulum) — the server-side half
|
|
||||||
* of the Refresh button; refreshAll() alone only re-reads caches. */
|
|
||||||
async function refreshRadio(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const res = await rpcClient.call<{ refreshed: boolean }>({ method: 'mesh.refresh' })
|
|
||||||
return !!res.refreshed
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
peers,
|
peers,
|
||||||
@ -1012,7 +983,6 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
error,
|
error,
|
||||||
sending,
|
sending,
|
||||||
unreadCounts,
|
unreadCounts,
|
||||||
viewingAtBottom,
|
|
||||||
totalUnread,
|
totalUnread,
|
||||||
nodePositions,
|
nodePositions,
|
||||||
federatedPositions,
|
federatedPositions,
|
||||||
@ -1032,7 +1002,6 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
broadcastIdentity,
|
broadcastIdentity,
|
||||||
configure,
|
configure,
|
||||||
refreshAll,
|
refreshAll,
|
||||||
refreshRadio,
|
|
||||||
markChatRead,
|
markChatRead,
|
||||||
clearViewingChat,
|
clearViewingChat,
|
||||||
sendInvoice,
|
sendInvoice,
|
||||||
|
|||||||
@ -1,166 +0,0 @@
|
|||||||
// Shared cache for RPC-backed page data (the "stale-while-revalidate" layer).
|
|
||||||
//
|
|
||||||
// Pages used to fetch-on-mount with a spinner on every navigation — Dashboard
|
|
||||||
// keys its router-view by route.path, so each visit unmounted and refetched
|
|
||||||
// everything. This store is the single place resource state lives instead:
|
|
||||||
// keyed entries survive navigation (Pinia) and reloads (sessionStorage
|
|
||||||
// snapshot), and `useCachedResource` renders them instantly while
|
|
||||||
// revalidating in the background.
|
|
||||||
//
|
|
||||||
// Semantics (generalized from homeStatus.ts / useFleetData.ts, the proven
|
|
||||||
// hand-rolled versions):
|
|
||||||
// - sticky-ready: once a key is 'ready' it never regresses to 'loading';
|
|
||||||
// refreshes show as 'refreshing' so the UI keeps the data visible.
|
|
||||||
// - keep-last-known-value on error: a failed revalidate leaves data in place
|
|
||||||
// (with `error` set and `fetchedAt` untouched → age badge shows staleness).
|
|
||||||
// - in-flight dedup per key: concurrent refreshes collapse into one fetch.
|
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { reactive } from 'vue'
|
|
||||||
|
|
||||||
export type ResourceLoadState = 'idle' | 'loading' | 'ready' | 'refreshing' | 'error'
|
|
||||||
|
|
||||||
export interface ResourceEntry<T = unknown> {
|
|
||||||
data: T | null
|
|
||||||
loadState: ResourceLoadState
|
|
||||||
/** Epoch ms of the last SUCCESSFUL fetch (drives TTL + stale badges). */
|
|
||||||
fetchedAt: number | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const SNAPSHOT_PREFIX = 'resource:'
|
|
||||||
|
|
||||||
function readSnapshot<T>(key: string): { data: T; fetchedAt: number } | null {
|
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem(SNAPSHOT_PREFIX + key)
|
|
||||||
if (!raw) return null
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (parsed && typeof parsed.fetchedAt === 'number' && 'data' in parsed) return parsed
|
|
||||||
} catch {
|
|
||||||
/* corrupt/absent snapshot — fall through to a fresh fetch */
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeSnapshot(key: string, data: unknown, fetchedAt: number): void {
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(SNAPSHOT_PREFIX + key, JSON.stringify({ data, fetchedAt }))
|
|
||||||
} catch {
|
|
||||||
/* quota exceeded or unserializable — memory cache still works */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useResourcesStore = defineStore('resources', () => {
|
|
||||||
const entries = reactive(new Map<string, ResourceEntry>())
|
|
||||||
// Non-reactive bookkeeping: in-flight fetches + active revalidators.
|
|
||||||
const inflight = new Map<string, Promise<void>>()
|
|
||||||
const revalidators = new Map<string, Set<() => void>>()
|
|
||||||
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
||||||
|
|
||||||
/** Get (or create) the reactive entry for a key, hydrating from the
|
|
||||||
* sessionStorage snapshot on first sight so revisits after a reload paint
|
|
||||||
* before any RPC completes. Pass `persist: false` to skip snapshots. */
|
|
||||||
function entry<T>(key: string, persist = true): ResourceEntry<T> {
|
|
||||||
let e = entries.get(key)
|
|
||||||
if (!e) {
|
|
||||||
const snap = persist ? readSnapshot<T>(key) : null
|
|
||||||
e = reactive<ResourceEntry>({
|
|
||||||
data: snap ? snap.data : null,
|
|
||||||
loadState: snap ? 'ready' : 'idle',
|
|
||||||
fetchedAt: snap ? snap.fetchedAt : null,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
entries.set(key, e)
|
|
||||||
}
|
|
||||||
return e as ResourceEntry<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run `fetcher` for `key` with sticky-ready + keep-last-value semantics.
|
|
||||||
* Concurrent calls for the same key share one in-flight fetch. */
|
|
||||||
function refresh<T>(
|
|
||||||
key: string,
|
|
||||||
fetcher: () => Promise<T>,
|
|
||||||
opts: { persist?: boolean } = {},
|
|
||||||
): Promise<void> {
|
|
||||||
const existing = inflight.get(key)
|
|
||||||
if (existing) return existing
|
|
||||||
const e = entry<T>(key, opts.persist ?? true)
|
|
||||||
e.loadState = e.loadState === 'ready' || e.loadState === 'refreshing' ? 'refreshing' : 'loading'
|
|
||||||
const p = (async () => {
|
|
||||||
try {
|
|
||||||
const data = await fetcher()
|
|
||||||
e.data = data
|
|
||||||
e.error = null
|
|
||||||
e.fetchedAt = Date.now()
|
|
||||||
e.loadState = 'ready'
|
|
||||||
if (opts.persist ?? true) writeSnapshot(key, data, e.fetchedAt)
|
|
||||||
} catch (err) {
|
|
||||||
e.error = err instanceof Error ? err.message : String(err)
|
|
||||||
// Keep last-known data visible; only 'error' when we have nothing.
|
|
||||||
e.loadState = e.data !== null ? 'ready' : 'error'
|
|
||||||
} finally {
|
|
||||||
inflight.delete(key)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
inflight.set(key, p)
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mark a key stale and (debounced) re-run every mounted subscriber's
|
|
||||||
* fetcher. Call after a mutation or on a relevant WS push. */
|
|
||||||
function invalidate(key: string, opts: { debounceMs?: number } = {}): void {
|
|
||||||
const e = entries.get(key)
|
|
||||||
if (e) e.fetchedAt = null
|
|
||||||
const subs = revalidators.get(key)
|
|
||||||
if (!subs || subs.size === 0) return
|
|
||||||
const t = invalidateTimers.get(key)
|
|
||||||
if (t) clearTimeout(t)
|
|
||||||
invalidateTimers.set(
|
|
||||||
key,
|
|
||||||
setTimeout(() => {
|
|
||||||
invalidateTimers.delete(key)
|
|
||||||
for (const fn of subs) fn()
|
|
||||||
}, opts.debounceMs ?? 800),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Register a live revalidator for a key (used by useCachedResource);
|
|
||||||
* returns an unsubscribe fn. */
|
|
||||||
function subscribe(key: string, revalidate: () => void): () => void {
|
|
||||||
let subs = revalidators.get(key)
|
|
||||||
if (!subs) {
|
|
||||||
subs = new Set()
|
|
||||||
revalidators.set(key, subs)
|
|
||||||
}
|
|
||||||
subs.add(revalidate)
|
|
||||||
return () => {
|
|
||||||
subs.delete(revalidate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Optimistically apply `update` to the cached value; returns a rollback.
|
|
||||||
* Pattern: rollback on RPC failure (generalized TransportPrefsCard). */
|
|
||||||
function optimistic<T>(key: string, update: (current: T | null) => T): () => void {
|
|
||||||
const e = entry<T>(key)
|
|
||||||
const before = e.data
|
|
||||||
const beforeState = e.loadState
|
|
||||||
e.data = update(before)
|
|
||||||
if (e.loadState === 'idle' || e.loadState === 'error') e.loadState = 'ready'
|
|
||||||
return () => {
|
|
||||||
e.data = before
|
|
||||||
e.loadState = beforeState
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop a key entirely (memory + snapshot). */
|
|
||||||
function evict(key: string): void {
|
|
||||||
entries.delete(key)
|
|
||||||
try {
|
|
||||||
sessionStorage.removeItem(SNAPSHOT_PREFIX + key)
|
|
||||||
} catch {
|
|
||||||
/* noop */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { entries, entry, refresh, invalidate, subscribe, optimistic, evict }
|
|
||||||
})
|
|
||||||
@ -2,38 +2,9 @@
|
|||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { DataModel, PatchOperation } from '../types/api'
|
import type { DataModel } from '../types/api'
|
||||||
import { wsClient, applyDataPatch } from '../api/websocket'
|
import { wsClient, applyDataPatch } from '../api/websocket'
|
||||||
import { rpcClient } from '../api/rpc-client'
|
import { rpcClient } from '../api/rpc-client'
|
||||||
import { useResourcesStore } from './resources'
|
|
||||||
|
|
||||||
/** Unescape one JSON-pointer segment (RFC 6901: ~1 → '/', ~0 → '~'). */
|
|
||||||
function pointerSegment(path: string, prefix: string): string {
|
|
||||||
const seg = path.slice(prefix.length).split('/')[0] ?? ''
|
|
||||||
return seg.replace(/~1/g, '/').replace(/~0/g, '~')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** B5: bridge /ws/db pushes into the cached-resource layer. Each patch op
|
|
||||||
* maps to the resource keys whose backing data it changes; invalidate()
|
|
||||||
* debounces (800ms) and only refetches keys with mounted subscribers, so a
|
|
||||||
* patch storm costs one revalidation per key. The 30s staleness
|
|
||||||
* reconciliation stays as the backstop for anything unmapped. */
|
|
||||||
function invalidateResourcesForPatch(patch: PatchOperation[]): void {
|
|
||||||
const resources = useResourcesStore()
|
|
||||||
for (const op of patch) {
|
|
||||||
const path = op.path ?? ''
|
|
||||||
if (path.startsWith('/peer-health/')) {
|
|
||||||
// A peer flipping reachability changes both its browse result and the
|
|
||||||
// federation node list's online state.
|
|
||||||
const onion = pointerSegment(path, '/peer-health/')
|
|
||||||
if (onion) resources.invalidate(`cloud.peer-browse:${onion}`)
|
|
||||||
resources.invalidate('federation.nodes')
|
|
||||||
} else if (path.startsWith('/package-data/')) {
|
|
||||||
// App installs/uninstalls add or remove their tor services.
|
|
||||||
resources.invalidate('server.tor-services')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSyncStore = defineStore('sync', () => {
|
export const useSyncStore = defineStore('sync', () => {
|
||||||
// State
|
// State
|
||||||
@ -137,7 +108,6 @@ export const useSyncStore = defineStore('sync', () => {
|
|||||||
try {
|
try {
|
||||||
if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown')
|
if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown')
|
||||||
data.value = applyDataPatch(data.value, update.patch)
|
data.value = applyDataPatch(data.value, update.patch)
|
||||||
invalidateResourcesForPatch(update.patch)
|
|
||||||
// Mark as connected once we receive any valid patch
|
// Mark as connected once we receive any valid patch
|
||||||
if (!isConnected.value) {
|
if (!isConnected.value) {
|
||||||
isConnected.value = true
|
isConnected.value = true
|
||||||
|
|||||||
@ -194,7 +194,7 @@
|
|||||||
Open Federation
|
Open Federation
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="filteredPeerFiles.length === 0 && peerFilesPending === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
<div v-else-if="filteredPeerFiles.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||||
{{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }}
|
{{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2">
|
||||||
@ -216,13 +216,6 @@
|
|||||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ f.peerName }}</span>
|
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ f.peerName }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="peerFilesPending > 0" class="text-[11px] text-white/35 text-center mt-3 flex items-center justify-center gap-2">
|
|
||||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Still fetching from {{ peerFilesPending }} peer{{ peerFilesPending === 1 ? '' : 's' }}…
|
|
||||||
</p>
|
|
||||||
<p v-if="peerFilesErrors > 0" class="text-[11px] text-white/35 text-center mt-3">
|
<p v-if="peerFilesErrors > 0" class="text-[11px] text-white/35 text-center mt-3">
|
||||||
{{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable — showing what answered.
|
{{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable — showing what answered.
|
||||||
</p>
|
</p>
|
||||||
@ -308,23 +301,12 @@
|
|||||||
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
|
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
|
||||||
{{ peer.trust_level }}
|
{{ peer.trust_level }}
|
||||||
</span>
|
</span>
|
||||||
<!-- Live transport badge — which route actually served the last
|
<span class="text-white/30">Peer Node</span>
|
||||||
browse (FIPS = direct mesh, fast; Tor = fallback, slow). -->
|
|
||||||
<span
|
|
||||||
v-if="peerTransport(peer.onion)"
|
|
||||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
|
|
||||||
:class="peerTransport(peer.onion)!.transport === 'fips' ? 'bg-emerald-500/15 text-emerald-300' : 'bg-amber-500/15 text-amber-300'"
|
|
||||||
:title="`Last browse served via ${peerTransport(peer.onion)!.transport.toUpperCase()}`"
|
|
||||||
>
|
|
||||||
<span class="w-1.5 h-1.5 rounded-full" :class="peerTransport(peer.onion)!.transport === 'fips' ? 'bg-emerald-400' : 'bg-amber-400'"></span>
|
|
||||||
{{ peerTransport(peer.onion)!.transport.toUpperCase() }} · {{ (peerTransport(peer.onion)!.latencyMs / 1000).toFixed(1) }}s
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-white/30">Peer Node</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="(peersLoading || peersRefreshing) && peerNodes.length > 0"
|
v-if="peersLoading && peerNodes.length > 0"
|
||||||
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
|
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||||
@ -406,8 +388,6 @@ import { computed, ref, watch, onMounted } from 'vue'
|
|||||||
import { useRouter, RouterLink } from 'vue-router'
|
import { useRouter, RouterLink } from 'vue-router'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { useCloudStore } from '../stores/cloud'
|
import { useCloudStore } from '../stores/cloud'
|
||||||
import { useResourcesStore } from '../stores/resources'
|
|
||||||
import { useCachedResource } from '../composables/useCachedResource'
|
|
||||||
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
|
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { getFileCategory } from '../composables/useFileType'
|
import { getFileCategory } from '../composables/useFileType'
|
||||||
@ -419,19 +399,9 @@ import MediaLightbox from '../components/cloud/MediaLightbox.vue'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const cloudStore = useCloudStore()
|
const cloudStore = useCloudStore()
|
||||||
const resources = useResourcesStore()
|
|
||||||
const audioPlayer = useAudioPlayer()
|
const audioPlayer = useAudioPlayer()
|
||||||
|
const sectionCounts = ref<Record<string, number>>({})
|
||||||
// Section counts — cached: revisits render the last-known counts instantly
|
const countsLoading = ref(false)
|
||||||
// and refresh in the background (sticky-ready never regresses to "Loading").
|
|
||||||
const countsResource = useCachedResource<Record<string, number>>({
|
|
||||||
key: 'cloud.section-counts',
|
|
||||||
fetcher: fetchCounts,
|
|
||||||
ttlMs: 30_000,
|
|
||||||
immediate: false, // gated on fileBrowserRunning; kicked from onMounted/watch
|
|
||||||
})
|
|
||||||
const sectionCounts = computed(() => countsResource.entry.data ?? {})
|
|
||||||
const countsLoading = computed(() => countsResource.entry.loadState === 'loading')
|
|
||||||
|
|
||||||
// ── Tabs / categories / search state ────────────────────────────────────────
|
// ── Tabs / categories / search state ────────────────────────────────────────
|
||||||
type TabId = 'folders' | 'mine' | 'peers' | 'paid'
|
type TabId = 'folders' | 'mine' | 'peers' | 'paid'
|
||||||
@ -454,18 +424,14 @@ const activeTab = ref<TabId>('folders')
|
|||||||
|
|
||||||
// ── Paid Files tab ──────────────────────────────────────────────────────────
|
// ── Paid Files tab ──────────────────────────────────────────────────────────
|
||||||
interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string }
|
interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string }
|
||||||
const paidResource = useCachedResource<PaidItem[]>({
|
const paidItems = ref<PaidItem[]>([])
|
||||||
key: 'cloud.paid-items',
|
const paidLoading = ref(false)
|
||||||
fetcher: async (signal) => {
|
async function loadPaidItems() {
|
||||||
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list', signal, dedup: true })
|
paidLoading.value = true
|
||||||
return (res.items || []).slice().reverse()
|
try {
|
||||||
},
|
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list' })
|
||||||
immediate: false, // loaded when the Paid tab is opened
|
paidItems.value = (res.items || []).slice().reverse()
|
||||||
})
|
} catch { paidItems.value = [] } finally { paidLoading.value = false }
|
||||||
const paidItems = computed(() => paidResource.entry.data ?? [])
|
|
||||||
const paidLoading = computed(() => paidResource.entry.loadState === 'loading')
|
|
||||||
function loadPaidItems() {
|
|
||||||
return paidResource.refresh()
|
|
||||||
}
|
}
|
||||||
async function viewPaidItem(it: PaidItem) {
|
async function viewPaidItem(it: PaidItem) {
|
||||||
try {
|
try {
|
||||||
@ -507,21 +473,8 @@ interface PeerNode {
|
|||||||
trust_level: string
|
trust_level: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Federation peers — cached so the Folders tab's peer cards paint instantly
|
const peerNodes = ref<PeerNode[]>([])
|
||||||
// on revisit while the list revalidates behind them.
|
const peersLoading = ref(true)
|
||||||
const peersResource = useCachedResource<PeerNode[]>({
|
|
||||||
key: 'cloud.peer-nodes',
|
|
||||||
fetcher: async (signal) => {
|
|
||||||
const result = await rpcClient.federationListNodes()
|
|
||||||
void signal
|
|
||||||
return result?.nodes ?? []
|
|
||||||
},
|
|
||||||
ttlMs: 30_000,
|
|
||||||
immediate: false, // kicked from onMounted (keeps the legacy load order)
|
|
||||||
})
|
|
||||||
const peerNodes = computed(() => peersResource.entry.data ?? [])
|
|
||||||
const peersLoading = computed(() => peersResource.entry.loadState === 'loading')
|
|
||||||
const peersRefreshing = computed(() => peersResource.entry.loadState === 'refreshing')
|
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
|
|
||||||
const APP_ALIASES: Record<string, string[]> = {
|
const APP_ALIASES: Record<string, string[]> = {
|
||||||
@ -626,51 +579,42 @@ function formatSize(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── My Files (flat list of every own file across the sections) ──────────────
|
// ── My Files (flat list of every own file across the sections) ──────────────
|
||||||
// Cached: revisiting the tab renders the last walk instantly and re-walks in
|
const myFiles = ref<FileBrowserItem[]>([])
|
||||||
// the background only when stale.
|
const myFilesLoading = ref(false)
|
||||||
const myFilesResource = useCachedResource<FileBrowserItem[]>({
|
const myFilesLoaded = ref(false)
|
||||||
key: 'cloud.my-files',
|
|
||||||
fetcher: fetchMyFiles,
|
|
||||||
ttlMs: 60_000,
|
|
||||||
immediate: false, // loaded when the My Files tab (or search) needs it
|
|
||||||
})
|
|
||||||
const myFiles = computed(() => myFilesResource.entry.data ?? [])
|
|
||||||
const myFilesLoading = computed(() => myFilesResource.entry.loadState === 'loading')
|
|
||||||
|
|
||||||
/** Depth-limited walk of the section folders; flat file list, capped. */
|
/** Depth-limited walk of the section folders; flat file list, capped. */
|
||||||
async function fetchMyFiles(): Promise<FileBrowserItem[]> {
|
async function loadMyFiles(force = false) {
|
||||||
if (!fileBrowserRunning.value) return []
|
if (myFilesLoading.value || (myFilesLoaded.value && !force)) return
|
||||||
const ok = await cloudStore.init()
|
if (!fileBrowserRunning.value) { myFilesLoaded.value = true; return }
|
||||||
if (!ok) return []
|
myFilesLoading.value = true
|
||||||
const out: FileBrowserItem[] = []
|
try {
|
||||||
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
|
const ok = await cloudStore.init()
|
||||||
if (sectionId === 'files') continue // '/' would double-visit the sections
|
if (!ok) return
|
||||||
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
|
const out: FileBrowserItem[] = []
|
||||||
while (queue.length > 0 && out.length < 500) {
|
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
|
||||||
const { path, depth } = queue.shift()!
|
if (sectionId === 'files') continue // '/' would double-visit the sections
|
||||||
let items: FileBrowserItem[]
|
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
|
||||||
try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
|
while (queue.length > 0 && out.length < 500) {
|
||||||
for (const item of items) {
|
const { path, depth } = queue.shift()!
|
||||||
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
|
let items: FileBrowserItem[]
|
||||||
if (item.isDir) {
|
try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
|
||||||
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
|
for (const item of items) {
|
||||||
} else {
|
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
|
||||||
out.push({ ...item, path: itemPath })
|
if (item.isDir) {
|
||||||
|
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
|
||||||
|
} else {
|
||||||
|
out.push({ ...item, path: itemPath })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
myFiles.value = out
|
||||||
|
myFilesLoaded.value = true
|
||||||
|
} finally {
|
||||||
|
myFilesLoading.value = false
|
||||||
}
|
}
|
||||||
out.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load if never fetched or stale; `force` always re-walks. */
|
|
||||||
function loadMyFiles(force = false): Promise<void> {
|
|
||||||
if (force) return myFilesResource.refresh()
|
|
||||||
if (myFilesResource.entry.data === null || myFilesResource.isStale.value) {
|
|
||||||
return myFilesResource.refresh()
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredMyFiles = computed(() =>
|
const filteredMyFiles = computed(() =>
|
||||||
@ -724,8 +668,7 @@ function handlePreview(path: string, context: FileBrowserItem[]) {
|
|||||||
async function handleDelete(path: string) {
|
async function handleDelete(path: string) {
|
||||||
try {
|
try {
|
||||||
await cloudStore.deleteItem(path)
|
await cloudStore.deleteItem(path)
|
||||||
// Delete confirmed — update the cache in place (no rollback needed).
|
myFiles.value = myFiles.value.filter(f => f.path !== path)
|
||||||
myFilesResource.optimistic((cur) => (cur ?? []).filter(f => f.path !== path))
|
|
||||||
searchResults.value = searchResults.value.filter(r => r.item?.path !== path)
|
searchResults.value = searchResults.value.filter(r => r.item?.path !== path)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError.value = e instanceof Error ? e.message : 'Delete failed'
|
loadError.value = e instanceof Error ? e.message : 'Delete failed'
|
||||||
@ -743,6 +686,11 @@ interface PeerFileEntry {
|
|||||||
peerOnion: string
|
peerOnion: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const peerFiles = ref<PeerFileEntry[]>([])
|
||||||
|
const peerFilesLoading = ref(false)
|
||||||
|
const peerFilesLoaded = ref(false)
|
||||||
|
const peerFilesErrors = ref(0)
|
||||||
|
|
||||||
interface CatalogItem {
|
interface CatalogItem {
|
||||||
id: string
|
id: string
|
||||||
filename: string
|
filename: string
|
||||||
@ -756,96 +704,46 @@ function priceOf(access: CatalogItem['access']): number {
|
|||||||
return typeof access === 'object' && access?.paid ? access.paid.price_sats : 0
|
return typeof access === 'object' && access?.paid ? access.paid.price_sats : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-peer browse results live as individual cached entries so (a) each
|
/** Fan out content.browse-peer over every federation node; tolerate stragglers. */
|
||||||
// peer's card/rows render the moment THAT peer answers — no more blocking on
|
|
||||||
// the slowest peer via Promise.allSettled — and (b) revisits paint from
|
|
||||||
// cache. The response's `transport` (fips/tor) + measured latency ride
|
|
||||||
// along, giving every peer a live transport badge.
|
|
||||||
interface PeerBrowse {
|
|
||||||
items: CatalogItem[]
|
|
||||||
transport: string | null
|
|
||||||
latencyMs: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const peerBrowseKey = (onion: string) => `cloud.peer-browse:${onion}`
|
|
||||||
|
|
||||||
function browsePeer(peer: PeerNode): Promise<void> {
|
|
||||||
return resources.refresh<PeerBrowse>(peerBrowseKey(peer.onion), async () => {
|
|
||||||
const t0 = Date.now()
|
|
||||||
const res = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
|
|
||||||
method: 'content.browse-peer',
|
|
||||||
params: { onion: peer.onion },
|
|
||||||
timeout: 30000,
|
|
||||||
// One slow/unreachable peer must cost its timeout ONCE, not ×3 —
|
|
||||||
// the retry loop is why one dead peer meant a 90s spinner.
|
|
||||||
maxRetries: 1,
|
|
||||||
dedup: true,
|
|
||||||
})
|
|
||||||
return { items: res?.items ?? [], transport: res?.transport ?? null, latencyMs: Date.now() - t0 }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function peerBrowseEntry(onion: string) {
|
|
||||||
return resources.entry<PeerBrowse>(peerBrowseKey(onion))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Transport badge data for a peer (null until its first browse resolves). */
|
|
||||||
function peerTransport(onion: string): { transport: string; latencyMs: number } | null {
|
|
||||||
const e = peerBrowseEntry(onion)
|
|
||||||
if (!e.data?.transport) return null
|
|
||||||
return { transport: e.data.transport, latencyMs: e.data.latencyMs }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Aggregated peer files, incrementally updated as each peer resolves. */
|
|
||||||
const peerFiles = computed<PeerFileEntry[]>(() => {
|
|
||||||
const merged: PeerFileEntry[] = []
|
|
||||||
for (const peer of peerNodes.value) {
|
|
||||||
const e = peerBrowseEntry(peer.onion)
|
|
||||||
if (!e.data) continue
|
|
||||||
const peerName = peer.name || peerDisplayName(peer.did)
|
|
||||||
for (const item of e.data.items) {
|
|
||||||
merged.push({
|
|
||||||
key: `${peer.onion}:${item.id}`,
|
|
||||||
filename: item.filename,
|
|
||||||
sizeBytes: item.size_bytes,
|
|
||||||
priceSats: priceOf(item.access),
|
|
||||||
category: categoryOf(item.mime_type || item.filename),
|
|
||||||
peerName,
|
|
||||||
peerOnion: peer.onion,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
merged.sort((a, b) => a.filename.localeCompare(b.filename))
|
|
||||||
return merged
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Peers still on their first in-flight browse (nothing cached yet). */
|
|
||||||
const peerFilesPending = computed(() =>
|
|
||||||
peerNodes.value.filter(p => {
|
|
||||||
const s = peerBrowseEntry(p.onion).loadState
|
|
||||||
return s === 'loading' || s === 'idle'
|
|
||||||
}).length,
|
|
||||||
)
|
|
||||||
/** Peers whose browse failed with no cached data to show. */
|
|
||||||
const peerFilesErrors = computed(() =>
|
|
||||||
peerNodes.value.filter(p => peerBrowseEntry(p.onion).loadState === 'error').length,
|
|
||||||
)
|
|
||||||
/** All-or-nothing spinner ONLY when nothing has ever been cached. */
|
|
||||||
const peerFilesLoading = computed(() =>
|
|
||||||
peerNodes.value.length > 0 && peerFiles.value.length === 0 && peerFilesPending.value > 0 && peerFilesErrors.value < peerNodes.value.length,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Fan out content.browse-peer; each peer renders as it resolves. */
|
|
||||||
async function loadPeerFiles(force = false) {
|
async function loadPeerFiles(force = false) {
|
||||||
if (peerNodes.value.length === 0) await loadPeers()
|
if (peerFilesLoading.value || (peerFilesLoaded.value && !force)) return
|
||||||
const targets = peerNodes.value.filter(p => {
|
peerFilesLoading.value = true
|
||||||
if (force) return true
|
peerFilesErrors.value = 0
|
||||||
const e = peerBrowseEntry(p.onion)
|
try {
|
||||||
const stale = e.fetchedAt === null || Date.now() - e.fetchedAt > 30_000
|
if (peerNodes.value.length === 0) await loadPeers()
|
||||||
return e.loadState === 'idle' || e.loadState === 'error' ? true : stale
|
const results = await Promise.allSettled(
|
||||||
})
|
peerNodes.value.map(async (peer) => {
|
||||||
// Fire-and-collect: the computed aggregation updates per resolution.
|
const res = await rpcClient.call<{ items?: CatalogItem[] }>({
|
||||||
await Promise.allSettled(targets.map(p => browsePeer(p)))
|
method: 'content.browse-peer',
|
||||||
|
params: { onion: peer.onion },
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
return { peer, items: res?.items ?? [] }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const merged: PeerFileEntry[] = []
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status !== 'fulfilled') { peerFilesErrors.value++; continue }
|
||||||
|
const { peer, items } = r.value
|
||||||
|
const peerName = peer.name || peerDisplayName(peer.did)
|
||||||
|
for (const item of items) {
|
||||||
|
merged.push({
|
||||||
|
key: `${peer.onion}:${item.id}`,
|
||||||
|
filename: item.filename,
|
||||||
|
sizeBytes: item.size_bytes,
|
||||||
|
priceSats: priceOf(item.access),
|
||||||
|
category: categoryOf(item.mime_type || item.filename),
|
||||||
|
peerName,
|
||||||
|
peerOnion: peer.onion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.sort((a, b) => a.filename.localeCompare(b.filename))
|
||||||
|
peerFiles.value = merged
|
||||||
|
peerFilesLoaded.value = true
|
||||||
|
} finally {
|
||||||
|
peerFilesLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredPeerFiles = computed(() =>
|
const filteredPeerFiles = computed(() =>
|
||||||
@ -923,50 +821,47 @@ const searchMineItems = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ── Existing counts / peers loading ──────────────────────────────────────────
|
// ── Existing counts / peers loading ──────────────────────────────────────────
|
||||||
async function fetchCounts(): Promise<Record<string, number>> {
|
async function loadCounts() {
|
||||||
if (!fileBrowserRunning.value) return {}
|
if (!fileBrowserRunning.value) return
|
||||||
const ok = await fileBrowserClient.login()
|
countsLoading.value = true
|
||||||
if (!ok) throw new Error('File Browser login failed')
|
try {
|
||||||
const counts: Record<string, number> = {}
|
const ok = await fileBrowserClient.login()
|
||||||
for (const section of contentSections) {
|
if (!ok) return
|
||||||
const path = SECTION_PATHS[section.id]
|
for (const section of contentSections) {
|
||||||
if (!path) continue
|
const path = SECTION_PATHS[section.id]
|
||||||
try {
|
if (!path) continue
|
||||||
counts[section.id] = (await fileBrowserClient.listDirectory(path)).length
|
try {
|
||||||
} catch {
|
const items = await fileBrowserClient.listDirectory(path)
|
||||||
counts[section.id] = 0
|
sectionCounts.value[section.id] = items.length
|
||||||
|
} catch {
|
||||||
|
sectionCounts.value[section.id] = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
return counts
|
loadError.value = e instanceof Error ? e.message : 'Failed to load file counts'
|
||||||
}
|
if (import.meta.env.DEV) console.warn('FileBrowser count loading failed', e)
|
||||||
|
} finally {
|
||||||
function loadCounts() {
|
countsLoading.value = false
|
||||||
if (countsResource.entry.data === null || countsResource.isStale.value) {
|
|
||||||
void countsResource.refresh()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
loadCounts()
|
loadCounts()
|
||||||
await loadPeers()
|
loadPeers()
|
||||||
// Warm the per-peer browse cache in the background: peer cards get their
|
|
||||||
// FIPS/Tor badge and the Peer Files tab is instant. Staleness-gated, so
|
|
||||||
// quick revisits don't refetch.
|
|
||||||
void loadPeerFiles()
|
|
||||||
})
|
|
||||||
|
|
||||||
// File Browser can finish its startup scan after we mount — pick counts up
|
|
||||||
// the moment it becomes available instead of showing a permanent blank.
|
|
||||||
watch(fileBrowserRunning, (running) => {
|
|
||||||
if (running) loadCounts()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadPeers() {
|
async function loadPeers() {
|
||||||
await peersResource.refresh()
|
const hadPeers = peerNodes.value.length > 0
|
||||||
// Surface refresh failures in the banner — the cached peer list stays
|
peersLoading.value = true
|
||||||
// visible either way (keep-last-known-value).
|
try {
|
||||||
const e = peersResource.entry
|
const result = await rpcClient.federationListNodes()
|
||||||
if (e.error) loadError.value = e.error
|
peerNodes.value = result?.nodes ?? []
|
||||||
|
} catch (e) {
|
||||||
|
if (!hadPeers) peerNodes.value = []
|
||||||
|
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
|
||||||
|
} finally {
|
||||||
|
peersLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function peerDisplayName(did: string): string {
|
function peerDisplayName(did: string): string {
|
||||||
|
|||||||
@ -306,12 +306,12 @@ const backLabel = computed(() => {
|
|||||||
return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder'
|
return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize native file browser when entering a native-UI section.
|
// Initialize native file browser when entering a native-UI section
|
||||||
// No reset() here: navigate() serves the per-path cache instantly and
|
|
||||||
// revalidates underneath — resetting wiped the listing and forced a
|
|
||||||
// spinner on every folder entry.
|
|
||||||
watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
|
watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
|
||||||
if (native && sec) {
|
if (native && sec) {
|
||||||
|
if (cloudStore.currentPath !== path) {
|
||||||
|
cloudStore.reset()
|
||||||
|
}
|
||||||
const ok = await cloudStore.init()
|
const ok = await cloudStore.init()
|
||||||
if (ok) {
|
if (ok) {
|
||||||
await cloudStore.navigate(path)
|
await cloudStore.navigate(path)
|
||||||
|
|||||||
@ -199,9 +199,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useCachedResource } from '@/composables/useCachedResource'
|
|
||||||
import BackButton from '@/components/BackButton.vue'
|
import BackButton from '@/components/BackButton.vue'
|
||||||
|
|
||||||
interface Identity {
|
interface Identity {
|
||||||
@ -229,30 +228,9 @@ interface Credential {
|
|||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cached: revisits paint identities/credentials instantly and revalidate
|
const identities = ref<Identity[]>([])
|
||||||
// behind them (errors keep the last-known lists).
|
const credentials = ref<Credential[]>([])
|
||||||
const identitiesRes = useCachedResource<Identity[]>({
|
const loadingCreds = ref(false)
|
||||||
key: 'credentials.identities',
|
|
||||||
fetcher: async (signal) => {
|
|
||||||
const result = await rpcClient.call<{ identities: Identity[] }>({
|
|
||||||
method: 'identity.list', params: {}, signal, dedup: true, maxRetries: 1,
|
|
||||||
})
|
|
||||||
return result?.identities || []
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const credentialsRes = useCachedResource<Credential[]>({
|
|
||||||
key: 'credentials.list',
|
|
||||||
fetcher: async (signal) => {
|
|
||||||
const result = await rpcClient.call<{ credentials: Credential[] }>({
|
|
||||||
method: 'identity.list-credentials', params: {}, signal, dedup: true, maxRetries: 1,
|
|
||||||
})
|
|
||||||
return result?.credentials || []
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const identities = computed(() => identitiesRes.data.value ?? [])
|
|
||||||
const credentials = computed(() => credentialsRes.data.value ?? [])
|
|
||||||
const loadingCreds = computed(() =>
|
|
||||||
credentialsRes.loadState.value === 'loading' || credentialsRes.loadState.value === 'refreshing')
|
|
||||||
const selectedCredential = ref<Credential | null>(null)
|
const selectedCredential = ref<Credential | null>(null)
|
||||||
const credCopied = ref(false)
|
const credCopied = ref(false)
|
||||||
const revoking = ref(false)
|
const revoking = ref(false)
|
||||||
@ -302,10 +280,31 @@ function formatClaims(subject: Record<string, unknown>): string {
|
|||||||
return JSON.stringify(claims, null, 2)
|
return JSON.stringify(claims, null, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadIdentities() {
|
||||||
|
try {
|
||||||
|
const result = await rpcClient.call<{ identities: Identity[] }>({
|
||||||
|
method: 'identity.list',
|
||||||
|
params: {},
|
||||||
|
})
|
||||||
|
identities.value = result.identities || []
|
||||||
|
} catch (e) {
|
||||||
|
identities.value = []
|
||||||
|
if (import.meta.env.DEV) console.warn('Failed to load identities:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCredentials() {
|
async function loadCredentials() {
|
||||||
await credentialsRes.refresh()
|
loadingCreds.value = true
|
||||||
if (credentialsRes.error.value) {
|
try {
|
||||||
showToast(`Failed to load credentials: ${credentialsRes.error.value}`, 'error')
|
const result = await rpcClient.call<{ credentials: Credential[] }>({
|
||||||
|
method: 'identity.list-credentials',
|
||||||
|
params: {},
|
||||||
|
})
|
||||||
|
credentials.value = result.credentials || []
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Failed to load credentials: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error')
|
||||||
|
} finally {
|
||||||
|
loadingCreds.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -405,8 +404,9 @@ async function copyCredentialJson() {
|
|||||||
setTimeout(() => { credCopied.value = false }, 2000)
|
setTimeout(() => { credCopied.value = false }, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both resources fetch themselves on first use (skipping the fetch entirely
|
onMounted(async () => {
|
||||||
// when the cached value is fresh).
|
await Promise.all([loadIdentities(), loadCredentials()])
|
||||||
|
})
|
||||||
|
|
||||||
defineExpose({ credentials, loadCredentials })
|
defineExpose({ credentials, loadCredentials })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -229,7 +229,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useCachedResource } from '@/composables/useCachedResource'
|
|
||||||
import { useTransportStore } from '@/stores/transport'
|
import { useTransportStore } from '@/stores/transport'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useSyncStore } from '@/stores/sync'
|
import { useSyncStore } from '@/stores/sync'
|
||||||
@ -251,15 +250,8 @@ const transportStore = useTransportStore()
|
|||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const syncStore = useSyncStore()
|
const syncStore = useSyncStore()
|
||||||
|
|
||||||
// Cached: revisits paint the node list instantly; the 5s poll and mutation
|
const nodes = ref<FederatedNode[]>([])
|
||||||
// refreshes revalidate behind it. `loading` is initial-load only (background
|
const loading = ref(true)
|
||||||
// refreshes keep content on screen — the old showLoader:false semantics).
|
|
||||||
const nodesRes = useCachedResource<FederatedNode[]>({
|
|
||||||
key: 'federation.nodes',
|
|
||||||
fetcher: async () => (await rpcClient.federationListNodes()).nodes,
|
|
||||||
})
|
|
||||||
const nodes = computed(() => nodesRes.data.value ?? [])
|
|
||||||
const loading = computed(() => nodesRes.loadState.value === 'loading')
|
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const selectedNode = ref<FederatedNode | null>(null)
|
const selectedNode = ref<FederatedNode | null>(null)
|
||||||
const inviteType = ref<'trusted' | 'observer'>('trusted')
|
const inviteType = ref<'trusted' | 'observer'>('trusted')
|
||||||
@ -328,12 +320,7 @@ const mapLinks = computed(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const dwnStatusRes = useCachedResource<DwnStatus>({
|
const dwnStatus = ref<DwnStatus | null>(null)
|
||||||
key: 'federation.dwn-status',
|
|
||||||
fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }),
|
|
||||||
immediate: false,
|
|
||||||
})
|
|
||||||
const dwnStatus = computed(() => dwnStatusRes.data.value)
|
|
||||||
const dwnSyncing = ref(false)
|
const dwnSyncing = ref(false)
|
||||||
|
|
||||||
const dwnSyncDotClass = computed(() => {
|
const dwnSyncDotClass = computed(() => {
|
||||||
@ -513,13 +500,25 @@ function isOnlineCheck(node: FederatedNode): boolean {
|
|||||||
return lastSeen > tenMinutesAgo
|
return lastSeen > tenMinutesAgo
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Explicit reload (mutations, retry): surfaces a load failure in the error
|
|
||||||
* banner. The background poll calls nodesRes.refresh() directly and stays
|
|
||||||
* silent, like the old surfaceErrors:false path. */
|
|
||||||
async function loadNodes() {
|
async function loadNodes() {
|
||||||
await nodesRes.refresh()
|
return loadNodesWithOptions()
|
||||||
if (nodesRes.error.value) error.value = nodesRes.error.value
|
}
|
||||||
else error.value = ''
|
|
||||||
|
async function loadNodesWithOptions(options: { showLoader?: boolean; surfaceErrors?: boolean } = {}) {
|
||||||
|
const showLoader = options.showLoader ?? nodes.value.length === 0
|
||||||
|
const surfaceErrors = options.surfaceErrors ?? true
|
||||||
|
try {
|
||||||
|
if (showLoader) loading.value = true
|
||||||
|
const result = await rpcClient.federationListNodes()
|
||||||
|
nodes.value = result.nodes
|
||||||
|
error.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
if (surfaceErrors) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load nodes'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (showLoader) loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGenerateInvite(type: 'trusted' | 'observer') {
|
function handleGenerateInvite(type: 'trusted' | 'observer') {
|
||||||
@ -611,8 +610,13 @@ async function deployApp(did: string, appId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadDwnStatus() {
|
async function loadDwnStatus() {
|
||||||
return dwnStatusRes.refresh()
|
try {
|
||||||
|
const result = await rpcClient.call<DwnStatus>({ method: 'dwn.status' })
|
||||||
|
dwnStatus.value = result
|
||||||
|
} catch {
|
||||||
|
dwnStatus.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function triggerDwnSync() {
|
async function triggerDwnSync() {
|
||||||
@ -677,6 +681,7 @@ async function rotateDid(password: string) {
|
|||||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
loadNodesWithOptions({ showLoader: true })
|
||||||
loadDwnStatus()
|
loadDwnStatus()
|
||||||
loadDiscoveryState()
|
loadDiscoveryState()
|
||||||
loadPendingRequests()
|
loadPendingRequests()
|
||||||
@ -689,7 +694,7 @@ onMounted(async () => {
|
|||||||
// Self DID not available
|
// Self DID not available
|
||||||
}
|
}
|
||||||
autoRefreshTimer = setInterval(() => {
|
autoRefreshTimer = setInterval(() => {
|
||||||
void nodesRes.refresh()
|
loadNodesWithOptions({ showLoader: false, surfaceErrors: false })
|
||||||
loadPendingRequests()
|
loadPendingRequests()
|
||||||
}, 5000)
|
}, 5000)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -38,8 +38,6 @@ const activeChatChannel = ref<{ index: number; name: string } | null>(null)
|
|||||||
const messageText = ref('')
|
const messageText = ref('')
|
||||||
const sendError = ref('')
|
const sendError = ref('')
|
||||||
const broadcasting = ref(false)
|
const broadcasting = ref(false)
|
||||||
const broadcastResult = ref<string | null>(null) // 'ok' | error message
|
|
||||||
const refreshing = ref(false)
|
|
||||||
const configuring = ref(false)
|
const configuring = ref(false)
|
||||||
const connectingDevice = ref<string | null>(null)
|
const connectingDevice = ref<string | null>(null)
|
||||||
// Device-detected onboarding now lives in the global MeshDeviceSetupModal (App.vue).
|
// Device-detected onboarding now lives in the global MeshDeviceSetupModal (App.vue).
|
||||||
@ -385,22 +383,12 @@ onMounted(async () => {
|
|||||||
archPollInterval = setInterval(loadArchMessages, 15000)
|
archPollInterval = setInterval(loadArchMessages, 15000)
|
||||||
}
|
}
|
||||||
if (!pollInterval) {
|
if (!pollInterval) {
|
||||||
let tick = 0
|
|
||||||
pollInterval = setInterval(() => {
|
pollInterval = setInterval(() => {
|
||||||
mesh.fetchStatus()
|
mesh.fetchStatus()
|
||||||
mesh.fetchPeers()
|
mesh.fetchPeers()
|
||||||
mesh.fetchMessages()
|
mesh.fetchMessages()
|
||||||
mesh.fetchDeadmanStatus()
|
mesh.fetchDeadmanStatus()
|
||||||
mesh.fetchBlockHeaders()
|
mesh.fetchBlockHeaders()
|
||||||
// Contacts/aliases, federation nodes and the outbox badge previously
|
|
||||||
// loaded ONCE at mount and went permanently stale — new federation
|
|
||||||
// peers or renames never appeared without a full page reload. Every
|
|
||||||
// 6th tick (~30s) keeps them fresh without adding per-5s load.
|
|
||||||
if (++tick % 6 === 0) {
|
|
||||||
void refreshContacts()
|
|
||||||
void refreshFederationNodes()
|
|
||||||
void refreshOutboxCount()
|
|
||||||
}
|
|
||||||
}, 5000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -896,14 +884,8 @@ function openChat(peer: MeshPeer) {
|
|||||||
messageText.value = ''
|
messageText.value = ''
|
||||||
activeTab.value = 'chat'
|
activeTab.value = 'chat'
|
||||||
mobileShowChat.value = true
|
mobileShowChat.value = true
|
||||||
nextTick(() => {
|
mesh.markChatRead(peer.contact_id)
|
||||||
// Clear unread only after the chat has rendered and auto-scrolled to the
|
nextTick(() => scrollChatToBottom())
|
||||||
// latest messages ("you've actually seen them"), and clear ALL twins of
|
|
||||||
// the merged conversation — the badge sums across every contact_id, so
|
|
||||||
// clearing just the clicked row's id left the badge stuck.
|
|
||||||
scrollChatToBottom()
|
|
||||||
mesh.markChatRead(activeMergedPeer.value?.contact_ids ?? peer.contact_id)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openChannelChat(channel: { index: number; name: string }) {
|
function openChannelChat(channel: { index: number; name: string }) {
|
||||||
@ -1015,25 +997,9 @@ async function handleSendMessage() {
|
|||||||
function scrollChatToBottom() {
|
function scrollChatToBottom() {
|
||||||
if (chatScrollEl.value) {
|
if (chatScrollEl.value) {
|
||||||
chatScrollEl.value.scrollTop = chatScrollEl.value.scrollHeight
|
chatScrollEl.value.scrollTop = chatScrollEl.value.scrollHeight
|
||||||
mesh.viewingAtBottom = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll-position read tracking: while the latest messages are in view the
|
|
||||||
// open conversation stays "read" (new arrivals don't badge); scrolled up into
|
|
||||||
// history, incoming messages accumulate unread like any normal chat app —
|
|
||||||
// scrolling back down clears them.
|
|
||||||
function onChatScroll() {
|
|
||||||
const el = chatScrollEl.value
|
|
||||||
if (!el) return
|
|
||||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
|
||||||
mesh.viewingAtBottom = nearBottom
|
|
||||||
if (nearBottom && activeMergedPeer.value) {
|
|
||||||
mesh.markChatRead(activeMergedPeer.value.contact_ids)
|
|
||||||
}
|
|
||||||
scheduleReadReceipt()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the compose field focused after a send so the user can keep typing
|
// Keep the compose field focused after a send so the user can keep typing
|
||||||
// without re-clicking it (Enter re-focuses natively, but the Send button
|
// without re-clicking it (Enter re-focuses natively, but the Send button
|
||||||
// click otherwise leaves focus on the button).
|
// click otherwise leaves focus on the button).
|
||||||
@ -1055,37 +1021,7 @@ function onChatWheel(e: WheelEvent) {
|
|||||||
|
|
||||||
async function handleBroadcast() {
|
async function handleBroadcast() {
|
||||||
broadcasting.value = true
|
broadcasting.value = true
|
||||||
broadcastResult.value = null
|
try { await mesh.broadcastIdentity() } finally { broadcasting.value = false }
|
||||||
try {
|
|
||||||
await mesh.broadcastIdentity()
|
|
||||||
broadcastResult.value = 'ok'
|
|
||||||
} catch (e) {
|
|
||||||
broadcastResult.value = e instanceof Error ? e.message : 'Broadcast failed'
|
|
||||||
} finally {
|
|
||||||
broadcasting.value = false
|
|
||||||
setTimeout(() => { broadcastResult.value = null }, 4000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRefresh() {
|
|
||||||
if (refreshing.value) return
|
|
||||||
refreshing.value = true
|
|
||||||
try {
|
|
||||||
// Backend first: re-query the radio's contact table (mesh.refresh), then
|
|
||||||
// re-read EVERYTHING the list is built from — peers, contacts/aliases,
|
|
||||||
// federation nodes, outbox — not just the mesh caches.
|
|
||||||
await Promise.allSettled([
|
|
||||||
mesh.refreshRadio(),
|
|
||||||
mesh.refreshAll(),
|
|
||||||
refreshContacts(),
|
|
||||||
refreshFederationNodes(),
|
|
||||||
refreshOutboxCount(),
|
|
||||||
])
|
|
||||||
// Radio contact refresh is async on the backend — pick up its result.
|
|
||||||
await mesh.fetchPeers()
|
|
||||||
} finally {
|
|
||||||
refreshing.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleEnabled() {
|
async function handleToggleEnabled() {
|
||||||
@ -1158,15 +1094,14 @@ interface PendingReply {
|
|||||||
}
|
}
|
||||||
const pendingReply = ref<PendingReply | null>(null)
|
const pendingReply = ref<PendingReply | null>(null)
|
||||||
const actionMenuForId = ref<number | null>(null)
|
const actionMenuForId = ref<number | null>(null)
|
||||||
|
const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏']
|
||||||
|
|
||||||
function openActionMenu(msgId: number, ev?: Event) {
|
function openActionMenu(msgId: number, ev?: Event) {
|
||||||
ev?.stopPropagation()
|
ev?.stopPropagation()
|
||||||
actionMenuForId.value = actionMenuForId.value === msgId ? null : msgId
|
actionMenuForId.value = actionMenuForId.value === msgId ? null : msgId
|
||||||
reactionPickerForId.value = null
|
|
||||||
}
|
}
|
||||||
function closeActionMenu() {
|
function closeActionMenu() {
|
||||||
actionMenuForId.value = null
|
actionMenuForId.value = null
|
||||||
reactionPickerForId.value = null
|
|
||||||
}
|
}
|
||||||
function handleDocClickForMenu(ev: MouseEvent) {
|
function handleDocClickForMenu(ev: MouseEvent) {
|
||||||
if (actionMenuForId.value === null) return
|
if (actionMenuForId.value === null) return
|
||||||
@ -1276,43 +1211,6 @@ function scheduleReadReceipt() {
|
|||||||
}, 400)
|
}, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hop visualization modal — click a message's transport pill to see how it
|
|
||||||
// traveled: radio hops + signal quality for LoRa transports, overlay/circuit
|
|
||||||
// shape for FIPS/Tor. Signal numbers are the PEER's current values (per-link,
|
|
||||||
// not stored per-message) — labelled as such in the modal.
|
|
||||||
const hopVizMsg = ref<MeshMessage | null>(null)
|
|
||||||
const hopVizPeer = computed<MeshPeer | null>(() => {
|
|
||||||
const m = hopVizMsg.value
|
|
||||||
if (!m) return null
|
|
||||||
return mesh.peers.find(p => p.contact_id === m.peer_contact_id) ?? activeChatPeer.value
|
|
||||||
})
|
|
||||||
function hopVizHops(): number | null {
|
|
||||||
const p = hopVizPeer.value
|
|
||||||
if (!p || p.hops == null || p.hops === 0xff) return null
|
|
||||||
return p.hops
|
|
||||||
}
|
|
||||||
function signalQualityLabel(snr: number | null, rssi: number | null): string {
|
|
||||||
if (snr == null && rssi == null) return 'signal unknown'
|
|
||||||
if (snr != null) {
|
|
||||||
if (snr > 5) return 'excellent signal'
|
|
||||||
if (snr > 0) return 'good signal'
|
|
||||||
if (snr > -10) return 'fair signal'
|
|
||||||
return 'weak signal'
|
|
||||||
}
|
|
||||||
if (rssi != null) {
|
|
||||||
if (rssi > -80) return 'excellent signal'
|
|
||||||
if (rssi > -100) return 'good signal'
|
|
||||||
if (rssi > -115) return 'fair signal'
|
|
||||||
return 'weak signal'
|
|
||||||
}
|
|
||||||
return 'signal unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reaction dropdown: the quick-reaction row lives behind a "React" toggle so
|
|
||||||
// the action menu stays compact and a larger emoji palette fits.
|
|
||||||
const reactionPickerForId = ref<number | null>(null)
|
|
||||||
const REACTION_PALETTE = ['👍', '❤️', '😂', '😮', '😢', '🙏', '🔥', '🎉', '💯', '👀', '😡', '🫡']
|
|
||||||
|
|
||||||
const reactionInFlight = ref<string | null>(null) // `${msgId}:${emoji}` while RPC is running
|
const reactionInFlight = ref<string | null>(null) // `${msgId}:${emoji}` while RPC is running
|
||||||
async function reactTo(msg: MeshMessage, emoji: string) {
|
async function reactTo(msg: MeshMessage, emoji: string) {
|
||||||
const key = messageKeyFor(msg)
|
const key = messageKeyFor(msg)
|
||||||
@ -1500,14 +1398,6 @@ function pickTransport(choice: 'mesh' | 'tor' | 'cancel') {
|
|||||||
const imageQualityChoice = ref<{ file: File } | null>(null)
|
const imageQualityChoice = ref<{ file: File } | null>(null)
|
||||||
const imageQualityEstimates = ref<Map<string, string>>(new Map())
|
const imageQualityEstimates = ref<Map<string, string>>(new Map())
|
||||||
let imageQualityResolve: ((preset: ImageCompressionPreset | null) => void) | null = null
|
let imageQualityResolve: ((preset: ImageCompressionPreset | null) => void) | null = null
|
||||||
// Transport pills at the bottom of the image modal: when the peer is also a
|
|
||||||
// federation node (FIPS overlay and/or Tor), the user can send the image that
|
|
||||||
// way instead of over LoRa. 'lora' keeps the existing radio flow; 'fips'/'tor'
|
|
||||||
// route via the blob/content-ref path (the dial layer prefers FIPS with Tor
|
|
||||||
// fallback — the pills are capability labels, defaulted to whichever of the
|
|
||||||
// two actually worked last for this peer).
|
|
||||||
const imageSendTransport = ref<'lora' | 'fips' | 'tor'>('lora')
|
|
||||||
const imageTransportOptions = ref<{ fips: boolean; tor: boolean }>({ fips: false, tor: false })
|
|
||||||
|
|
||||||
function formatEstSeconds(seconds: number): string {
|
function formatEstSeconds(seconds: number): string {
|
||||||
if (seconds < 60) return `~${seconds}s`
|
if (seconds < 60) return `~${seconds}s`
|
||||||
@ -1517,8 +1407,6 @@ function formatEstSeconds(seconds: number): string {
|
|||||||
async function openImageQualityDialog(file: File, peerContactId: number): Promise<ImageCompressionPreset | null> {
|
async function openImageQualityDialog(file: File, peerContactId: number): Promise<ImageCompressionPreset | null> {
|
||||||
imageQualityChoice.value = { file }
|
imageQualityChoice.value = { file }
|
||||||
imageQualityEstimates.value = new Map()
|
imageQualityEstimates.value = new Map()
|
||||||
imageSendTransport.value = 'lora'
|
|
||||||
imageTransportOptions.value = { fips: false, tor: false }
|
|
||||||
// Fire off estimates for all presets in parallel — each preset's nominal
|
// Fire off estimates for all presets in parallel — each preset's nominal
|
||||||
// target size (or the real file size for 'original') against the SAME
|
// target size (or the real file size for 'original') against the SAME
|
||||||
// mesh.transport-advice RPC the non-image attach flow already uses.
|
// mesh.transport-advice RPC the non-image attach flow already uses.
|
||||||
@ -1530,7 +1418,6 @@ async function openImageQualityDialog(file: File, peerContactId: number): Promis
|
|||||||
const label =
|
const label =
|
||||||
advice.tier === 'impossible' ? 'too large' : formatEstSeconds(advice.est_seconds)
|
advice.tier === 'impossible' ? 'too large' : formatEstSeconds(advice.est_seconds)
|
||||||
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, label)
|
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, label)
|
||||||
imageTransportOptions.value = { fips: advice.has_fips, tor: advice.has_tor }
|
|
||||||
} catch {
|
} catch {
|
||||||
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, '?')
|
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, '?')
|
||||||
}
|
}
|
||||||
@ -1659,18 +1546,12 @@ async function handleAttachFile(ev: Event) {
|
|||||||
attaching.value = true
|
attaching.value = true
|
||||||
attachError.value = null
|
attachError.value = null
|
||||||
try {
|
try {
|
||||||
let forcedTransport: 'fips' | 'tor' | null = null
|
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
const preset = await openImageQualityDialog(file, peer.contact_id)
|
const preset = await openImageQualityDialog(file, peer.contact_id)
|
||||||
if (!preset) return // user cancelled
|
if (!preset) return // user cancelled
|
||||||
if (imageSendTransport.value !== 'lora') forcedTransport = imageSendTransport.value
|
|
||||||
file = await compressImage(file, preset)
|
file = await compressImage(file, preset)
|
||||||
}
|
}
|
||||||
if (forcedTransport) {
|
if (!(await sendFileViaBestTransport(file, peer))) return
|
||||||
// User picked a FIPS/Tor pill in the image modal — skip the LoRa
|
|
||||||
// advice/chooser flow entirely and ship via the content-ref path.
|
|
||||||
await sendViaTorContentRef(file, peer.contact_id, peer.advert_name)
|
|
||||||
} else if (!(await sendFileViaBestTransport(file, peer))) return
|
|
||||||
messageText.value = ''
|
messageText.value = ''
|
||||||
nextTick(() => scrollChatToBottom())
|
nextTick(() => scrollChatToBottom())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -1949,14 +1830,8 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
<button class="glass-button mesh-action-btn" :disabled="configuring" @click="handleToggleEnabled">
|
<button class="glass-button mesh-action-btn" :disabled="configuring" @click="handleToggleEnabled">
|
||||||
{{ mesh.status?.enabled ? 'Disable' : 'Enable' }}
|
{{ mesh.status?.enabled ? 'Disable' : 'Enable' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="glass-button mesh-action-btn" :disabled="!mesh.status?.device_connected || broadcasting" @click="handleBroadcast">
|
||||||
class="glass-button mesh-action-btn"
|
{{ broadcasting ? 'Sending...' : 'Broadcast' }}
|
||||||
:class="broadcastResult === 'ok' ? 'mesh-action-ok' : ''"
|
|
||||||
:disabled="!mesh.status?.device_connected || broadcasting"
|
|
||||||
:title="broadcastResult && broadcastResult !== 'ok' ? broadcastResult : 'Announce this node so nearby radios learn about it'"
|
|
||||||
@click="handleBroadcast"
|
|
||||||
>
|
|
||||||
{{ broadcasting ? 'Sending…' : broadcastResult === 'ok' ? 'Sent ✓' : broadcastResult ? 'Failed ✕' : 'Broadcast' }}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="glass-button mesh-action-btn"
|
class="glass-button mesh-action-btn"
|
||||||
@ -1966,12 +1841,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
>
|
>
|
||||||
{{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }}
|
{{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }}
|
||||||
</button>
|
</button>
|
||||||
<!-- While refreshing show ONLY the spinner — spinner + "Refreshing…"
|
<button class="glass-button mesh-action-btn" @click="mesh.refreshAll()">Refresh</button>
|
||||||
together overflow the button's fixed width. -->
|
|
||||||
<button class="glass-button mesh-action-btn" :disabled="refreshing" @click="handleRefresh" :title="refreshing ? 'Refreshing…' : 'Re-query the radio and reload peers, contacts and federation nodes'">
|
|
||||||
<span v-if="refreshing" class="mesh-refresh-spinner" aria-hidden="true"></span>
|
|
||||||
<template v-if="!refreshing">Refresh</template>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Peers list -->
|
<!-- Peers list -->
|
||||||
@ -2001,10 +1871,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
>×</button>
|
>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Only claim "no peers" when the MERGED list (radio + federation)
|
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
|
||||||
is truly empty — with no radio attached the federation rows and
|
|
||||||
the two channel rows must still render. -->
|
|
||||||
<div v-if="displayedPeers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
|
|
||||||
No peers discovered yet.
|
No peers discovered yet.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -2161,7 +2028,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
|
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="onChatScroll" @wheel.stop.prevent="onChatWheel">
|
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.stop.prevent="onChatWheel">
|
||||||
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
|
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
|
||||||
No messages yet. Say hello!
|
No messages yet. Say hello!
|
||||||
</div>
|
</div>
|
||||||
@ -2273,14 +2140,8 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
<button
|
<button
|
||||||
class="mesh-typed-content-download-btn"
|
class="mesh-typed-content-download-btn"
|
||||||
title="Download"
|
title="Download"
|
||||||
aria-label="Download image"
|
|
||||||
@click="downloadAttachment(msg.typed_payload as any)"
|
@click="downloadAttachment(msg.typed_payload as any)"
|
||||||
>
|
>⬇</button>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M12 4v11m0 0l-4.5-4.5M12 15l4.5-4.5" />
|
|
||||||
<path d="M5 19h14" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<audio
|
<audio
|
||||||
v-else-if="(msg.typed_payload.mime || '').startsWith('audio/')"
|
v-else-if="(msg.typed_payload.mime || '').startsWith('audio/')"
|
||||||
@ -2304,19 +2165,10 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
@click="openMeshLightbox(msg.typed_payload as any)"
|
@click="openMeshLightbox(msg.typed_payload as any)"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="mesh-typed-content-fetch-btn"
|
class="btn"
|
||||||
:disabled="fetchingCids.has(msg.typed_payload.cid)"
|
:disabled="fetchingCids.has(msg.typed_payload.cid)"
|
||||||
@click="handleFetchContent(msg.typed_payload as any)"
|
@click="handleFetchContent(msg.typed_payload as any)"
|
||||||
>
|
>
|
||||||
<span
|
|
||||||
v-if="fetchingCids.has(msg.typed_payload.cid)"
|
|
||||||
class="mesh-refresh-spinner"
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M12 4v11m0 0l-4.5-4.5M12 15l4.5-4.5" />
|
|
||||||
<path d="M5 19h14" />
|
|
||||||
</svg>
|
|
||||||
{{ fetchingCids.has(msg.typed_payload.cid) ? 'Fetching…' : 'Download' }}
|
{{ fetchingCids.has(msg.typed_payload.cid) ? 'Fetching…' : 'Download' }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
@ -2331,7 +2183,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
<!-- Default: plain text -->
|
<!-- Default: plain text -->
|
||||||
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
|
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
|
||||||
<div class="mesh-chat-bubble-meta">
|
<div class="mesh-chat-bubble-meta">
|
||||||
<span v-if="transportLabel(msg)" class="mesh-chat-transport mesh-chat-transport-clickable" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg) + ' — click for route details'" @click.stop="hopVizMsg = msg">{{ transportLabel(msg) }}</span>
|
<span v-if="transportLabel(msg)" class="mesh-chat-transport" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg)">{{ transportLabel(msg) }}</span>
|
||||||
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
|
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
|
||||||
<span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</span>
|
<span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</span>
|
||||||
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">✓✓</span>
|
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">✓✓</span>
|
||||||
@ -2362,25 +2214,17 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startEditOf(msg)">Edit</button>
|
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startEditOf(msg)">Edit</button>
|
||||||
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn mesh-chat-action-danger" :disabled="reactionInFlight !== null" @click="deleteOwnMessage(msg)">Delete</button>
|
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn mesh-chat-action-danger" :disabled="reactionInFlight !== null" @click="deleteOwnMessage(msg)">Delete</button>
|
||||||
<button
|
<button
|
||||||
class="mesh-chat-action-btn"
|
v-for="emoji in QUICK_REACTIONS"
|
||||||
:class="{ active: reactionPickerForId === msg.id }"
|
:key="emoji"
|
||||||
|
class="mesh-chat-reaction-btn"
|
||||||
|
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
|
||||||
:disabled="reactionInFlight !== null"
|
:disabled="reactionInFlight !== null"
|
||||||
@click="reactionPickerForId = reactionPickerForId === msg.id ? null : msg.id"
|
@click="reactTo(msg, emoji)"
|
||||||
>😀 React ▾</button>
|
>
|
||||||
|
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
|
||||||
|
<span v-else>{{ emoji }}</span>
|
||||||
|
</button>
|
||||||
<button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="closeActionMenu">✕</button>
|
<button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="closeActionMenu">✕</button>
|
||||||
<div v-if="reactionPickerForId === msg.id" class="mesh-chat-reaction-dropdown">
|
|
||||||
<button
|
|
||||||
v-for="emoji in REACTION_PALETTE"
|
|
||||||
:key="emoji"
|
|
||||||
class="mesh-chat-reaction-btn"
|
|
||||||
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
|
|
||||||
:disabled="reactionInFlight !== null"
|
|
||||||
@click="reactTo(msg, emoji)"
|
|
||||||
>
|
|
||||||
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
|
|
||||||
<span v-else>{{ emoji }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2519,11 +2363,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<!-- Transport chooser modal: shown when attachment size fits both mesh
|
<!-- Transport chooser modal: shown when attachment size fits both mesh
|
||||||
(inline-chunked) and Tor. User picks which path to send it over.
|
(inline-chunked) and Tor. User picks which path to send it over. -->
|
||||||
Teleported to body so the fixed backdrop covers the FULL viewport —
|
|
||||||
rendered in place it sits inside a transformed/filtered glass panel,
|
|
||||||
which traps position:fixed to just the right chat panel. -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">
|
<div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">
|
||||||
<div class="glass-card mesh-transport-modal">
|
<div class="glass-card mesh-transport-modal">
|
||||||
<h3 class="mesh-transport-title">📎 How should I send this?</h3>
|
<h3 class="mesh-transport-title">📎 How should I send this?</h3>
|
||||||
@ -2552,13 +2392,10 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
<button class="mesh-transport-cancel" @click="pickTransport('cancel')">Cancel</button>
|
<button class="mesh-transport-cancel" @click="pickTransport('cancel')">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- Image quality-picker modal: shown before sending an image attachment.
|
<!-- Image quality-picker modal: shown before sending an image attachment.
|
||||||
Each preset shows its nominal size target + a transfer-time estimate
|
Each preset shows its nominal size target + a transfer-time estimate
|
||||||
from the same mesh.transport-advice RPC the file-attach flow uses.
|
from the same mesh.transport-advice RPC the file-attach flow uses. -->
|
||||||
Teleported to body — see the transport chooser above. -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="imageQualityChoice" class="mesh-transport-modal-backdrop" @click.self="pickImageQuality(null)">
|
<div v-if="imageQualityChoice" class="mesh-transport-modal-backdrop" @click.self="pickImageQuality(null)">
|
||||||
<div class="glass-card mesh-transport-modal">
|
<div class="glass-card mesh-transport-modal">
|
||||||
<h3 class="mesh-transport-title">🖼️ Choose Image Quality</h3>
|
<h3 class="mesh-transport-title">🖼️ Choose Image Quality</h3>
|
||||||
@ -2575,91 +2412,12 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
|||||||
>
|
>
|
||||||
<span class="mesh-transport-icon">🖼️</span>
|
<span class="mesh-transport-icon">🖼️</span>
|
||||||
<span class="mesh-transport-label">{{ preset.displayName }} — {{ preset.description }}</span>
|
<span class="mesh-transport-label">{{ preset.displayName }} — {{ preset.description }}</span>
|
||||||
<span class="mesh-transport-meta">{{
|
<span class="mesh-transport-meta">{{ imageQualityEstimates.get(preset.key) ?? '…' }}</span>
|
||||||
imageSendTransport === 'lora' ? (imageQualityEstimates.get(preset.key) ?? '…') : 'instant'
|
|
||||||
}}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Transport pills: LoRa is always available (it's a mesh chat);
|
|
||||||
FIPS/Tor appear when the peer is a reachable federation node,
|
|
||||||
letting the user send the image that way instead. -->
|
|
||||||
<div
|
|
||||||
v-if="imageTransportOptions.fips || imageTransportOptions.tor"
|
|
||||||
class="mesh-image-transport-row"
|
|
||||||
>
|
|
||||||
<span class="mesh-image-transport-caption">Send via</span>
|
|
||||||
<button
|
|
||||||
class="mesh-image-transport-pill"
|
|
||||||
:class="{ active: imageSendTransport === 'lora' }"
|
|
||||||
@click="imageSendTransport = 'lora'"
|
|
||||||
>📡 LoRa</button>
|
|
||||||
<button
|
|
||||||
v-if="imageTransportOptions.fips"
|
|
||||||
class="mesh-image-transport-pill"
|
|
||||||
:class="{ active: imageSendTransport === 'fips' }"
|
|
||||||
@click="imageSendTransport = 'fips'"
|
|
||||||
>⚡ FIPS</button>
|
|
||||||
<button
|
|
||||||
v-if="imageTransportOptions.tor"
|
|
||||||
class="mesh-image-transport-pill"
|
|
||||||
:class="{ active: imageSendTransport === 'tor' }"
|
|
||||||
@click="imageSendTransport = 'tor'"
|
|
||||||
>🧅 Tor</button>
|
|
||||||
</div>
|
|
||||||
<button class="mesh-transport-cancel" @click="pickImageQuality(null)">Cancel</button>
|
<button class="mesh-transport-cancel" @click="pickImageQuality(null)">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- Hop visualization modal: click a message's transport pill to see how
|
|
||||||
it traveled — radio hops + live signal for LoRa transports, overlay/
|
|
||||||
circuit shape for FIPS/Tor. Teleported to body (full-screen backdrop). -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="hopVizMsg" class="mesh-transport-modal-backdrop" @click.self="hopVizMsg = null">
|
|
||||||
<div class="glass-card mesh-transport-modal">
|
|
||||||
<h3 class="mesh-transport-title">{{ transportLabel(hopVizMsg) }} route</h3>
|
|
||||||
<p class="mesh-transport-sub">
|
|
||||||
{{ hopVizMsg.direction === 'sent' ? 'You → ' + (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') + ' → You' }}
|
|
||||||
</p>
|
|
||||||
<div class="mesh-hopviz-chain">
|
|
||||||
<div class="mesh-hopviz-node">
|
|
||||||
<span class="mesh-hopviz-icon">🏝️</span>
|
|
||||||
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? 'You' : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') }}</span>
|
|
||||||
</div>
|
|
||||||
<template v-if="hopVizMsg.transport === 'tor'">
|
|
||||||
<div class="mesh-hopviz-link">🧅 3 anonymous relays</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="hopVizMsg.transport === 'fips'">
|
|
||||||
<div class="mesh-hopviz-link">⚡ FIPS overlay · direct peer-to-peer</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="mesh-hopviz-link">
|
|
||||||
📡 {{ hopVizHops() === null || hopVizHops() === 0 ? 'direct radio link' : `${hopVizHops()} hop${hopVizHops() === 1 ? '' : 's'}` }}
|
|
||||||
<span v-if="hopVizHops() !== null && hopVizHops()! > 0" class="mesh-hopviz-dots">
|
|
||||||
<span v-for="i in Math.min(hopVizHops()!, 6)" :key="i" class="mesh-hopviz-dot" title="relay node">•</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="mesh-hopviz-node">
|
|
||||||
<span class="mesh-hopviz-icon">🏝️</span>
|
|
||||||
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') : 'You' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="hopVizMsg.transport !== 'tor' && hopVizMsg.transport !== 'fips'" class="mesh-hopviz-signal">
|
|
||||||
<span>{{ signalQualityLabel(hopVizPeer?.snr ?? null, hopVizPeer?.rssi ?? null) }}</span>
|
|
||||||
<span v-if="hopVizPeer?.snr != null" class="mesh-hopviz-meta">SNR {{ hopVizPeer.snr.toFixed(1) }} dB</span>
|
|
||||||
<span v-if="hopVizPeer?.rssi != null" class="mesh-hopviz-meta">RSSI {{ hopVizPeer.rssi }} dBm</span>
|
|
||||||
<p class="mesh-hopviz-note">Signal values are the current link readings for this peer, not a snapshot from this message.</p>
|
|
||||||
</div>
|
|
||||||
<div class="mesh-hopviz-signal">
|
|
||||||
<span v-if="hopVizMsg.encrypted" class="mesh-chat-e2e">E2E</span>
|
|
||||||
<span class="mesh-hopviz-meta">{{ hopVizMsg.delivered && hopVizMsg.direction === 'sent' ? 'delivered ✓✓' : hopVizMsg.direction === 'sent' ? 'sent' : 'received' }}</span>
|
|
||||||
<span class="mesh-hopviz-meta">{{ timeAgo(hopVizMsg.timestamp) }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="mesh-transport-cancel" @click="hopVizMsg = null">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- The "mesh device detected" setup flow is now the global
|
<!-- The "mesh device detected" setup flow is now the global
|
||||||
MeshDeviceSetupModal mounted in App.vue (fires on every page,
|
MeshDeviceSetupModal mounted in App.vue (fires on every page,
|
||||||
|
|||||||
@ -218,7 +218,6 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useCachedResource } from '@/composables/useCachedResource'
|
|
||||||
import { useHomeStatusStore } from '@/stores/homeStatus'
|
import { useHomeStatusStore } from '@/stores/homeStatus'
|
||||||
import BackButton from '@/components/BackButton.vue'
|
import BackButton from '@/components/BackButton.vue'
|
||||||
import LineChart from '@/components/LineChart.vue'
|
import LineChart from '@/components/LineChart.vue'
|
||||||
@ -292,51 +291,11 @@ const backTarget = computed(() => (cameFromHome.value ? '/dashboard' : '/dashboa
|
|||||||
const backLabel = computed(() => (cameFromHome.value ? t('common.back') : 'Web5'))
|
const backLabel = computed(() => (cameFromHome.value ? t('common.back') : 'Web5'))
|
||||||
const homeStatus = useHomeStatusStore()
|
const homeStatus = useHomeStatusStore()
|
||||||
|
|
||||||
// Cached: revisits paint the last snapshot/chart/alerts instantly and the 5s
|
const current = ref<MetricSnapshot | null>(null)
|
||||||
// poll revalidates behind them; errors keep the last-known values.
|
const history = ref<MetricSnapshot[]>([])
|
||||||
const currentRes = useCachedResource<MetricSnapshot>({
|
const containers = ref<ContainerMetrics[]>([])
|
||||||
key: 'monitoring.current',
|
const alerts = ref<FiredAlert[]>([])
|
||||||
fetcher: async (signal) => {
|
const alertRules = ref<AlertRule[]>([])
|
||||||
const data = await rpcClient.call<MetricSnapshot | { status: string }>({
|
|
||||||
method: 'monitoring.current', signal, dedup: true, maxRetries: 1,
|
|
||||||
})
|
|
||||||
if (!data || !('system' in data)) throw new Error('metrics not ready')
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const historyRes = useCachedResource<MetricSnapshot[]>({
|
|
||||||
key: 'monitoring.history.minute60',
|
|
||||||
fetcher: async (signal) => {
|
|
||||||
const data = await rpcClient.call<HistoryResponse>({
|
|
||||||
method: 'monitoring.history', params: { resolution: 'minute', count: 60 },
|
|
||||||
signal, dedup: true, maxRetries: 1,
|
|
||||||
})
|
|
||||||
return data?.data ?? []
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const alertsRes = useCachedResource<FiredAlert[]>({
|
|
||||||
key: 'monitoring.alerts',
|
|
||||||
fetcher: async (signal) => {
|
|
||||||
const data = await rpcClient.call<{ alerts: FiredAlert[] }>({
|
|
||||||
method: 'monitoring.alerts', params: { count: 50 }, signal, dedup: true, maxRetries: 1,
|
|
||||||
})
|
|
||||||
return (data?.alerts ?? []).reverse()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const alertRulesRes = useCachedResource<AlertRule[]>({
|
|
||||||
key: 'monitoring.alert-rules',
|
|
||||||
fetcher: async (signal) => {
|
|
||||||
const data = await rpcClient.call<{ rules: AlertRule[] }>({
|
|
||||||
method: 'monitoring.alert-rules', signal, dedup: true, maxRetries: 1,
|
|
||||||
})
|
|
||||||
return data?.rules ?? []
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const current = computed(() => currentRes.data.value)
|
|
||||||
const history = computed(() => historyRes.data.value ?? [])
|
|
||||||
const containers = computed<ContainerMetrics[]>(() => currentRes.data.value?.containers ?? [])
|
|
||||||
const alerts = computed(() => alertsRes.data.value ?? [])
|
|
||||||
const alertRules = computed(() => alertRulesRes.data.value ?? [])
|
|
||||||
const showAlertConfig = ref(false)
|
const showAlertConfig = ref(false)
|
||||||
const chartWidth = ref(380)
|
const chartWidth = ref(380)
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
@ -505,10 +464,66 @@ async function exportMetrics(format: 'csv' | 'json') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchCurrent() {
|
||||||
|
try {
|
||||||
|
await homeStatus.refreshSystemStats()
|
||||||
|
const data = await rpcClient.call<MetricSnapshot | { status: string }>({
|
||||||
|
method: 'monitoring.current',
|
||||||
|
})
|
||||||
|
if (data && 'system' in data) {
|
||||||
|
current.value = data
|
||||||
|
containers.value = data.containers ?? []
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently retry on next poll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHistory() {
|
||||||
|
try {
|
||||||
|
const data = await rpcClient.call<HistoryResponse>({
|
||||||
|
method: 'monitoring.history',
|
||||||
|
params: { resolution: 'minute', count: 60 },
|
||||||
|
})
|
||||||
|
if (data?.data) {
|
||||||
|
history.value = data.data
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently retry on next poll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAlerts() {
|
||||||
|
try {
|
||||||
|
const data = await rpcClient.call<{ alerts: FiredAlert[] }>({
|
||||||
|
method: 'monitoring.alerts',
|
||||||
|
params: { count: 50 },
|
||||||
|
})
|
||||||
|
if (data?.alerts) {
|
||||||
|
alerts.value = data.alerts.reverse()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently retry on next poll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAlertRules() {
|
||||||
|
try {
|
||||||
|
const data = await rpcClient.call<{ rules: AlertRule[] }>({
|
||||||
|
method: 'monitoring.alert-rules',
|
||||||
|
})
|
||||||
|
if (data?.rules) {
|
||||||
|
alertRules.value = data.rules
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleAlertRule(kind: string, enabled: boolean) {
|
async function toggleAlertRule(kind: string, enabled: boolean) {
|
||||||
try {
|
try {
|
||||||
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, enabled } })
|
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, enabled } })
|
||||||
await alertRulesRes.refresh()
|
await fetchAlertRules()
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical
|
// Non-critical
|
||||||
}
|
}
|
||||||
@ -519,7 +534,7 @@ async function updateThreshold(kind: string, value: string) {
|
|||||||
if (isNaN(threshold) || threshold <= 0) return
|
if (isNaN(threshold) || threshold <= 0) return
|
||||||
try {
|
try {
|
||||||
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, threshold } })
|
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, threshold } })
|
||||||
await alertRulesRes.refresh()
|
await fetchAlertRules()
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical
|
// Non-critical
|
||||||
}
|
}
|
||||||
@ -528,7 +543,7 @@ async function updateThreshold(kind: string, value: string) {
|
|||||||
async function acknowledgeAlert(id: string) {
|
async function acknowledgeAlert(id: string) {
|
||||||
try {
|
try {
|
||||||
await rpcClient.call({ method: 'monitoring.acknowledge-alert', params: { id } })
|
await rpcClient.call({ method: 'monitoring.acknowledge-alert', params: { id } })
|
||||||
await alertsRes.refresh()
|
await fetchAlerts()
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical
|
// Non-critical
|
||||||
}
|
}
|
||||||
@ -541,18 +556,18 @@ function updateChartWidth() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
updateChartWidth()
|
updateChartWidth()
|
||||||
window.addEventListener('resize', updateChartWidth)
|
window.addEventListener('resize', updateChartWidth)
|
||||||
|
|
||||||
// The cached resources fetch themselves on first use; the poll keeps the
|
await Promise.all([fetchCurrent(), fetchHistory(), fetchAlerts(), fetchAlertRules()])
|
||||||
// live view fresh (refreshes dedup in the store).
|
|
||||||
void homeStatus.refreshSystemStats()
|
pollTimer = setInterval(async () => {
|
||||||
pollTimer = setInterval(() => {
|
try {
|
||||||
void homeStatus.refreshSystemStats()
|
await Promise.all([fetchCurrent(), fetchHistory(), fetchAlerts()])
|
||||||
void currentRes.refresh()
|
} catch {
|
||||||
void historyRes.refresh()
|
// Background poll — ignore transient errors
|
||||||
void alertsRes.refresh()
|
}
|
||||||
}, 5000)
|
}, 5000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -594,11 +594,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, reactive, watch, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useResourcesStore } from '@/stores/resources'
|
|
||||||
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
||||||
import { pipSupported, togglePip } from '@/utils/pip'
|
import { pipSupported, togglePip } from '@/utils/pip'
|
||||||
import BackButton from '@/components/BackButton.vue'
|
import BackButton from '@/components/BackButton.vue'
|
||||||
@ -626,33 +625,16 @@ interface CatalogItem {
|
|||||||
access: string | { paid: { price_sats: number } }
|
access: string | { paid: { price_sats: number } }
|
||||||
}
|
}
|
||||||
|
|
||||||
const resources = useResourcesStore()
|
const loading = ref(true)
|
||||||
const currentPeer = ref<PeerNode | null>(null)
|
const currentPeer = ref<PeerNode | null>(null)
|
||||||
|
const catalogError = ref('')
|
||||||
|
const catalogItems = ref<CatalogItem[]>([])
|
||||||
const downloading = ref<string | null>(null)
|
const downloading = ref<string | null>(null)
|
||||||
const playing = ref<string | null>(null)
|
const playing = ref<string | null>(null)
|
||||||
const purchaseError = ref<string | null>(null)
|
const purchaseError = ref<string | null>(null)
|
||||||
|
|
||||||
// The catalog is the SAME cached entry Cloud.vue's per-peer fan-in fills
|
|
||||||
// (`cloud.peer-browse:<onion>`): arriving here from the Cloud page paints
|
|
||||||
// the file list instantly from cache and revalidates behind it.
|
|
||||||
interface PeerBrowse {
|
|
||||||
items: CatalogItem[]
|
|
||||||
transport: string | null
|
|
||||||
latencyMs: number
|
|
||||||
}
|
|
||||||
const peerOnion = computed(() => props.peerId || currentPeer.value?.onion || '')
|
|
||||||
function browseEntry() {
|
|
||||||
return resources.entry<PeerBrowse>(`cloud.peer-browse:${peerOnion.value}`)
|
|
||||||
}
|
|
||||||
const catalogItems = computed(() => browseEntry().data?.items ?? [])
|
|
||||||
const catalogError = computed(() => browseEntry().error ?? '')
|
|
||||||
const loading = computed(() => {
|
|
||||||
const s = browseEntry().loadState
|
|
||||||
return s === 'loading' || s === 'refreshing' || (s === 'idle' && !!peerOnion.value)
|
|
||||||
})
|
|
||||||
// Transport actually used to reach this peer (returned by content.browse-peer)
|
// Transport actually used to reach this peer (returned by content.browse-peer)
|
||||||
// so we can show a FIPS/Tor pill instead of always assuming Tor (B21).
|
// so we can show a FIPS/Tor pill instead of always assuming Tor (B21).
|
||||||
const transport = computed(() => browseEntry().data?.transport ?? null)
|
const transport = ref<string | null>(null)
|
||||||
const transportPill = computed(() => {
|
const transportPill = computed(() => {
|
||||||
switch (transport.value) {
|
switch (transport.value) {
|
||||||
case 'fips':
|
case 'fips':
|
||||||
@ -825,62 +807,44 @@ onMounted(async () => {
|
|||||||
loadCatalog(),
|
loadCatalog(),
|
||||||
loadOwned(),
|
loadOwned(),
|
||||||
])
|
])
|
||||||
|
} else {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
// No peerId → peerOnion is empty and `loading` stays false on its own.
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function loadCatalog(): Promise<void> {
|
async function loadCatalog() {
|
||||||
const onion = peerOnion.value
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!onion) return Promise.resolve()
|
if (!onion) return
|
||||||
return resources.refresh<PeerBrowse>(`cloud.peer-browse:${onion}`, async () => {
|
const hadItems = catalogItems.value.length > 0
|
||||||
const t0 = Date.now()
|
loading.value = true
|
||||||
|
catalogError.value = ''
|
||||||
|
try {
|
||||||
const result = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
|
const result = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
|
||||||
method: 'content.browse-peer',
|
method: 'content.browse-peer',
|
||||||
params: { onion },
|
params: { onion },
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
// The caller has its own timeout UX; retry×3 turned one slow peer
|
|
||||||
// into a 90s spinner.
|
|
||||||
maxRetries: 1,
|
|
||||||
dedup: true,
|
|
||||||
})
|
|
||||||
return { items: result?.items ?? [], transport: result?.transport ?? null, latencyMs: Date.now() - t0 }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load visual previews for image and video items when catalog loads.
|
|
||||||
// Audio files don't need visual thumbnails — they show a waveform icon.
|
|
||||||
// The fan-out is capped (3 concurrent) and aborts on unmount — it used to
|
|
||||||
// fire one 30s RPC per media item all at once, unbounded.
|
|
||||||
const previewAborter = new AbortController()
|
|
||||||
onUnmounted(() => previewAborter.abort())
|
|
||||||
const previewQueued = new Set<string>()
|
|
||||||
let previewQueue: CatalogItem[] = []
|
|
||||||
let previewWorkers = 0
|
|
||||||
const PREVIEW_CONCURRENCY = 3
|
|
||||||
|
|
||||||
function pumpPreviews(onion: string) {
|
|
||||||
while (previewWorkers < PREVIEW_CONCURRENCY && previewQueue.length > 0) {
|
|
||||||
const item = previewQueue.shift()!
|
|
||||||
previewWorkers++
|
|
||||||
void loadPreview(onion, item).finally(() => {
|
|
||||||
previewWorkers--
|
|
||||||
pumpPreviews(onion)
|
|
||||||
})
|
})
|
||||||
|
catalogItems.value = result?.items ?? []
|
||||||
|
transport.value = result?.transport ?? null
|
||||||
|
} catch (e: unknown) {
|
||||||
|
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
|
||||||
|
if (!hadItems) catalogItems.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(catalogItems, (items) => {
|
// Load visual previews for image and video items when catalog loads
|
||||||
const onion = peerOnion.value
|
// Audio files don't need visual thumbnails — they show a waveform icon
|
||||||
|
watch(catalogItems, async (items) => {
|
||||||
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!onion) return
|
if (!onion) return
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const isVisual = item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')
|
if ((item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')) && !previewUrls[item.id]) {
|
||||||
if (isVisual && !previewUrls[item.id] && !previewQueued.has(item.id)) {
|
loadPreview(onion, item)
|
||||||
previewQueued.add(item.id)
|
|
||||||
previewQueue.push(item)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pumpPreviews(onion)
|
})
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
async function loadPreview(onion: string, item: CatalogItem) {
|
async function loadPreview(onion: string, item: CatalogItem) {
|
||||||
try {
|
try {
|
||||||
@ -888,8 +852,6 @@ async function loadPreview(onion: string, item: CatalogItem) {
|
|||||||
method: 'content.preview-peer',
|
method: 'content.preview-peer',
|
||||||
params: { onion, content_id: item.id },
|
params: { onion, content_id: item.id },
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
maxRetries: 1,
|
|
||||||
signal: previewAborter.signal,
|
|
||||||
})
|
})
|
||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
const mime = result.content_type || item.mime_type
|
const mime = result.content_type || item.mime_type
|
||||||
|
|||||||
@ -407,7 +407,6 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useCachedResource, type CachedResource } from '@/composables/useCachedResource'
|
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import QuickActionsCard from './server/QuickActionsCard.vue'
|
import QuickActionsCard from './server/QuickActionsCard.vue'
|
||||||
import TorServicesCard from './server/TorServicesCard.vue'
|
import TorServicesCard from './server/TorServicesCard.vue'
|
||||||
@ -435,51 +434,18 @@ const torStatusColor = computed(() => {
|
|||||||
const autoSyncEnabled = ref(true)
|
const autoSyncEnabled = ref(true)
|
||||||
const logCount = ref(0)
|
const logCount = ref(0)
|
||||||
|
|
||||||
// Network data — a cached aggregate over four RPCs (allSettled: a failing
|
// Network data
|
||||||
// one keeps that slice's previous values). Revisits paint instantly.
|
const networkLoading = ref(true)
|
||||||
interface NetworkData {
|
const networkRefreshing = ref(false)
|
||||||
wifiCount: string; wifiSsid: string | null; torConnected: boolean; forwardCount: string
|
const networkHasLoaded = ref(false)
|
||||||
vpnConnected: boolean; vpnProvider: string; vpnIp: string; wgIp: string; wgPubkey: string
|
const networkData = ref({
|
||||||
vpnHostname: string; vpnPeers: number
|
wifiCount: 'N/A', wifiSsid: null as string | null, torConnected: false, forwardCount: 'N/A',
|
||||||
dnsProvider: string; dnsServers: string[]; dnsDoH: boolean
|
|
||||||
}
|
|
||||||
const defaultNetworkData = (): NetworkData => ({
|
|
||||||
wifiCount: 'N/A', wifiSsid: null, torConnected: false, forwardCount: 'N/A',
|
|
||||||
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
|
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
|
||||||
dnsProvider: 'system', dnsServers: [], dnsDoH: false,
|
dnsProvider: 'system', dnsServers: [] as string[], dnsDoH: false,
|
||||||
})
|
})
|
||||||
// immediate:false — the fetcher merges onto the previous value via
|
|
||||||
// networkRes, so it must not run during this initializer (onMounted loads
|
|
||||||
// it). The explicit annotation breaks the self-referential inference cycle.
|
|
||||||
const networkRes: CachedResource<NetworkData> = useCachedResource<NetworkData>({
|
|
||||||
key: 'server.network-summary',
|
|
||||||
immediate: false,
|
|
||||||
fetcher: async () => {
|
|
||||||
const next = { ...(networkRes.data.value ?? defaultNetworkData()) }
|
|
||||||
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
|
|
||||||
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
|
|
||||||
rpcClient.call<{ forwards: unknown[] }>({ method: 'router.list-forwards' }),
|
|
||||||
rpcClient.vpnStatus(),
|
|
||||||
rpcClient.dnsStatus(),
|
|
||||||
])
|
|
||||||
if (diagRes.status === 'fulfilled') { next.torConnected = diagRes.value.tor_connected; next.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A'; next.wifiSsid = (diagRes.value as { wifi_ssid?: string | null }).wifi_ssid ?? null }
|
|
||||||
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; next.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
|
|
||||||
if (vpnRes.status === 'fulfilled') { next.vpnConnected = vpnRes.value.connected; next.vpnProvider = vpnRes.value.provider ?? ''; next.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); next.wgIp = vpnRes.value.wg_ip ?? ''; next.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
|
|
||||||
if (dnsRes.status === 'fulfilled') { next.dnsProvider = dnsRes.value.provider; next.dnsServers = dnsRes.value.resolv_conf_servers ?? []; next.dnsDoH = dnsRes.value.doh_enabled }
|
|
||||||
return next
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const networkData = computed(() => networkRes.data.value ?? defaultNetworkData())
|
|
||||||
const networkLoading = computed(() => networkRes.loadState.value === 'loading')
|
|
||||||
const networkRefreshing = computed(() => networkRes.loadState.value === 'refreshing')
|
|
||||||
|
|
||||||
// FIPS status row for the Local Network card. Full FIPS card lives below.
|
// FIPS status row for the Local Network card. Full FIPS card lives below.
|
||||||
const fipsSummaryRes = useCachedResource<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({
|
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
|
||||||
key: 'server.fips-summary',
|
|
||||||
immediate: false,
|
|
||||||
fetcher: (signal) => rpcClient.call({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }),
|
|
||||||
})
|
|
||||||
const fipsSummary = computed(() => fipsSummaryRes.data.value)
|
|
||||||
const fipsRowLabel = computed(() => {
|
const fipsRowLabel = computed(() => {
|
||||||
const s = fipsSummary.value
|
const s = fipsSummary.value
|
||||||
if (!s) return '…'
|
if (!s) return '…'
|
||||||
@ -501,12 +467,32 @@ const fipsRowTextClass = computed(() => {
|
|||||||
if (s.anchor_connected === false) return 'text-orange-400'
|
if (s.anchor_connected === false) return 'text-orange-400'
|
||||||
return 'text-green-400'
|
return 'text-green-400'
|
||||||
})
|
})
|
||||||
function loadFipsSummary() {
|
async function loadFipsSummary() {
|
||||||
return fipsSummaryRes.refresh()
|
try {
|
||||||
|
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ method: 'fips.status' })
|
||||||
|
} catch { /* backend too old */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadNetworkData() {
|
async function loadNetworkData() {
|
||||||
return networkRes.refresh()
|
const initialLoad = !networkHasLoaded.value
|
||||||
|
networkLoading.value = initialLoad
|
||||||
|
networkRefreshing.value = !initialLoad
|
||||||
|
try {
|
||||||
|
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
|
||||||
|
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
|
||||||
|
rpcClient.call<{ forwards: unknown[] }>({ method: 'router.list-forwards' }),
|
||||||
|
rpcClient.vpnStatus(),
|
||||||
|
rpcClient.dnsStatus(),
|
||||||
|
])
|
||||||
|
if (diagRes.status === 'fulfilled') { networkData.value.torConnected = diagRes.value.tor_connected; networkData.value.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A'; networkData.value.wifiSsid = (diagRes.value as { wifi_ssid?: string | null }).wifi_ssid ?? null }
|
||||||
|
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
|
||||||
|
if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; networkData.value.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
|
||||||
|
if (dnsRes.status === 'fulfilled') { networkData.value.dnsProvider = dnsRes.value.provider; networkData.value.dnsServers = dnsRes.value.resolv_conf_servers ?? []; networkData.value.dnsDoH = dnsRes.value.doh_enabled }
|
||||||
|
} catch { /* keep existing/default values */ } finally {
|
||||||
|
networkHasLoaded.value = true
|
||||||
|
networkLoading.value = false
|
||||||
|
networkRefreshing.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// VPN peer management
|
// VPN peer management
|
||||||
@ -521,18 +507,13 @@ const sanitizedPeerQrSvg = computed(() =>
|
|||||||
)
|
)
|
||||||
const peerError = ref('')
|
const peerError = ref('')
|
||||||
const copiedConfig = ref(false)
|
const copiedConfig = ref(false)
|
||||||
const vpnPeersRes = useCachedResource<{ name: string; ip: string; type?: string; npub?: string }[]>({
|
const vpnPeers = ref<{ name: string; ip: string; type?: string; npub?: string }[]>([])
|
||||||
key: 'server.vpn-peers',
|
|
||||||
immediate: false,
|
|
||||||
fetcher: async (signal) => {
|
|
||||||
const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers', signal, dedup: true, maxRetries: 1 })
|
|
||||||
return res.peers || []
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const vpnPeers = computed(() => vpnPeersRes.data.value ?? [])
|
|
||||||
|
|
||||||
function loadVpnPeers() {
|
async function loadVpnPeers() {
|
||||||
return vpnPeersRes.refresh()
|
try {
|
||||||
|
const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers' })
|
||||||
|
vpnPeers.value = res.peers || []
|
||||||
|
} catch { /* no peers */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPeer() {
|
async function createPeer() {
|
||||||
@ -576,7 +557,7 @@ async function removePeer(name: string) {
|
|||||||
removingPeer.value = name
|
removingPeer.value = name
|
||||||
try {
|
try {
|
||||||
await rpcClient.call({ method: 'vpn.remove-peer', params: { name } })
|
await rpcClient.call({ method: 'vpn.remove-peer', params: { name } })
|
||||||
vpnPeersRes.optimistic(cur => (cur ?? []).filter(p => p.name !== name))
|
vpnPeers.value = vpnPeers.value.filter(p => p.name !== name)
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
finally { removingPeer.value = '' }
|
finally { removingPeer.value = '' }
|
||||||
}
|
}
|
||||||
@ -602,17 +583,10 @@ async function copyPeerConfig() {
|
|||||||
interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] }
|
interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] }
|
||||||
interface WifiNetwork { ssid: string; signal: number; security: string }
|
interface WifiNetwork { ssid: string; signal: number; security: string }
|
||||||
|
|
||||||
const interfacesRes = useCachedResource<NetworkInterface[]>({
|
const interfacesLoading = ref(true)
|
||||||
key: 'server.interfaces',
|
const interfacesRefreshing = ref(false)
|
||||||
immediate: false,
|
const interfacesHaveLoaded = ref(false)
|
||||||
fetcher: async (signal) => {
|
const allInterfaces = ref<NetworkInterface[]>([])
|
||||||
const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces', signal, dedup: true, maxRetries: 1 })
|
|
||||||
return res.interfaces
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const interfacesLoading = computed(() => interfacesRes.loadState.value === 'loading')
|
|
||||||
const interfacesRefreshing = computed(() => interfacesRes.loadState.value === 'refreshing')
|
|
||||||
const allInterfaces = computed(() => interfacesRes.data.value ?? [])
|
|
||||||
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi'))
|
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi'))
|
||||||
const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi'))
|
const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi'))
|
||||||
|
|
||||||
@ -663,19 +637,19 @@ async function applyDnsConfig(customServers: string) {
|
|||||||
const res = await rpcClient.configureDns(params)
|
const res = await rpcClient.configureDns(params)
|
||||||
// Never trust the response shape: an undefined `servers` used to reach the
|
// Never trust the response shape: an undefined `servers` used to reach the
|
||||||
// dnsDisplayLabel computed and crash the whole page render on `.length`.
|
// dnsDisplayLabel computed and crash the whole page render on `.length`.
|
||||||
// Write-through to the cached aggregate (the RPC already succeeded).
|
networkData.value.dnsProvider = res?.provider ?? provider
|
||||||
networkRes.optimistic(cur => ({
|
networkData.value.dnsServers = Array.isArray(res?.servers) ? res.servers : (params.servers ?? [])
|
||||||
...(cur ?? defaultNetworkData()),
|
networkData.value.dnsDoH = !!res?.doh_enabled
|
||||||
dnsProvider: res?.provider ?? provider,
|
|
||||||
dnsServers: Array.isArray(res?.servers) ? res.servers : (params.servers ?? []),
|
|
||||||
dnsDoH: !!res?.doh_enabled,
|
|
||||||
}))
|
|
||||||
showDnsModal.value = false
|
showDnsModal.value = false
|
||||||
} catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false }
|
} catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadInterfaces() {
|
async function loadInterfaces() {
|
||||||
return interfacesRes.refresh()
|
const initialLoad = !interfacesHaveLoaded.value
|
||||||
|
const hadInterfaces = allInterfaces.value.length > 0
|
||||||
|
interfacesLoading.value = initialLoad
|
||||||
|
interfacesRefreshing.value = !initialLoad
|
||||||
|
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleWifiRadio(iface: NetworkInterface) {
|
async function toggleWifiRadio(iface: NetworkInterface) {
|
||||||
@ -757,18 +731,9 @@ function formatBytes(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tor Services
|
// Tor Services
|
||||||
const torServicesRes = useCachedResource<{ services: TorServiceInfo[]; tor_running: boolean }>({
|
const torServices = ref<TorServiceInfo[]>([])
|
||||||
key: 'server.tor-services',
|
const torServicesLoading = ref(false)
|
||||||
immediate: false,
|
const torDaemonRunning = ref(false)
|
||||||
fetcher: async (signal) => {
|
|
||||||
const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services', signal, dedup: true, maxRetries: 1 })
|
|
||||||
return { services: res.services || [], tor_running: res.tor_running ?? false }
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const torServices = computed(() => torServicesRes.data.value?.services ?? [])
|
|
||||||
const torServicesLoading = computed(() =>
|
|
||||||
torServicesRes.loadState.value === 'loading' || torServicesRes.loadState.value === 'refreshing')
|
|
||||||
const torDaemonRunning = computed(() => torServicesRes.data.value?.tor_running ?? false)
|
|
||||||
const torRestarting = ref(false)
|
const torRestarting = ref(false)
|
||||||
const torRotating = ref<string | false>(false)
|
const torRotating = ref<string | false>(false)
|
||||||
const torDeleting = ref<string | false>(false)
|
const torDeleting = ref<string | false>(false)
|
||||||
@ -785,8 +750,11 @@ const availableAppsForTor = computed(() => {
|
|||||||
.sort((a, b) => a.title.localeCompare(b.title))
|
.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
})
|
})
|
||||||
|
|
||||||
function loadTorServices() {
|
async function loadTorServices() {
|
||||||
return torServicesRes.refresh()
|
const hadServices = torServices.value.length > 0
|
||||||
|
torServicesLoading.value = true
|
||||||
|
try { const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torDaemonRunning.value = res.tor_running ?? false }
|
||||||
|
catch { if (!hadServices) { torServices.value = []; torDaemonRunning.value = false } } finally { torServicesLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyTorAddress(address: string) {
|
async function copyTorAddress(address: string) {
|
||||||
@ -830,18 +798,14 @@ async function createService(name: string, port: number | null) {
|
|||||||
|
|
||||||
onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() })
|
onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() })
|
||||||
|
|
||||||
// Poll VPN status every 15s so IP updates after pairing (write-through to
|
// Poll VPN status every 15s so IP updates after pairing
|
||||||
// the cached aggregate without refetching the other three RPCs)
|
|
||||||
const vpnPollInterval = setInterval(async () => {
|
const vpnPollInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const vpnRes = await rpcClient.vpnStatus()
|
const vpnRes = await rpcClient.vpnStatus()
|
||||||
networkRes.optimistic(cur => ({
|
networkData.value.vpnConnected = vpnRes.connected
|
||||||
...(cur ?? defaultNetworkData()),
|
networkData.value.vpnProvider = vpnRes.provider ?? ''
|
||||||
vpnConnected: vpnRes.connected,
|
networkData.value.vpnIp = (vpnRes.ip_address ?? '').replace(/\/\d+$/, '')
|
||||||
vpnProvider: vpnRes.provider ?? '',
|
networkData.value.wgIp = vpnRes.wg_ip ?? ''
|
||||||
vpnIp: (vpnRes.ip_address ?? '').replace(/\/\d+$/, ''),
|
|
||||||
wgIp: vpnRes.wg_ip ?? '',
|
|
||||||
}))
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, 15000)
|
}, 15000)
|
||||||
onUnmounted(() => clearInterval(vpnPollInterval))
|
onUnmounted(() => clearInterval(vpnPollInterval))
|
||||||
@ -865,11 +829,8 @@ async function restartServices() {
|
|||||||
|
|
||||||
async function checkTorStatus() {
|
async function checkTorStatus() {
|
||||||
checkingTor.value = true; torStatusLabel.value = 'checking'
|
checkingTor.value = true; torStatusLabel.value = 'checking'
|
||||||
try {
|
try { const res = await rpcClient.call<{ services: TorServiceInfo[] }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torStatusLabel.value = torServices.value.some(s => s.onion_address) ? 'running' : 'stopped' }
|
||||||
await torServicesRes.refresh()
|
catch { torStatusLabel.value = 'stopped' } finally { checkingTor.value = false }
|
||||||
if (torServicesRes.error.value) torStatusLabel.value = 'stopped'
|
|
||||||
else torStatusLabel.value = torServices.value.some(s => s.onion_address) ? 'running' : 'stopped'
|
|
||||||
} finally { checkingTor.value = false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const logsToast = ref('')
|
const logsToast = ref('')
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import Credentials from '../Credentials.vue'
|
import Credentials from '../Credentials.vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
@ -43,8 +42,6 @@ describe('Credentials', () => {
|
|||||||
|
|
||||||
const wrapper = mount(Credentials, {
|
const wrapper = mount(Credentials, {
|
||||||
global: {
|
global: {
|
||||||
// The cached-resource layer pulls the Pinia resources store in setup.
|
|
||||||
plugins: [createPinia()],
|
|
||||||
mocks: {
|
mocks: {
|
||||||
$router: { push: vi.fn() },
|
$router: { push: vi.fn() },
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import PeerFiles from '../PeerFiles.vue'
|
import PeerFiles from '../PeerFiles.vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
@ -56,8 +55,6 @@ describe('PeerFiles', () => {
|
|||||||
const wrapper = mount(PeerFiles, {
|
const wrapper = mount(PeerFiles, {
|
||||||
props: { peerId: 'peer.onion' },
|
props: { peerId: 'peer.onion' },
|
||||||
global: {
|
global: {
|
||||||
// The shared peer-browse cache lives in the Pinia resources store.
|
|
||||||
plugins: [createPinia()],
|
|
||||||
stubs: {
|
stubs: {
|
||||||
Teleport: true,
|
Teleport: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import Server from '../Server.vue'
|
import Server from '../Server.vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
@ -30,8 +29,6 @@ function deferred<T>() {
|
|||||||
function mountServer(options: { renderTorServices?: boolean } = {}) {
|
function mountServer(options: { renderTorServices?: boolean } = {}) {
|
||||||
return mount(Server, {
|
return mount(Server, {
|
||||||
global: {
|
global: {
|
||||||
// The cached-resource layer pulls the Pinia resources store in setup.
|
|
||||||
plugins: [createPinia()],
|
|
||||||
stubs: {
|
stubs: {
|
||||||
QuickActionsCard: true,
|
QuickActionsCard: true,
|
||||||
TorServicesCard: options.renderTorServices ? false : true,
|
TorServicesCard: options.renderTorServices ? false : true,
|
||||||
|
|||||||
@ -142,15 +142,12 @@ async function saveSettings() {
|
|||||||
lora_region: form.value.region,
|
lora_region: form.value.region,
|
||||||
device_kind: form.value.deviceKind,
|
device_kind: form.value.deviceKind,
|
||||||
channel_name: form.value.channel.trim() || 'archipelago',
|
channel_name: form.value.channel.trim() || 'archipelago',
|
||||||
// Always sent: an empty string CLEARS the custom mesh name (backend
|
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
|
||||||
// maps "" -> None -> fall back to the server name). The old omit-when-
|
|
||||||
// empty made clearing impossible once a name was ever set.
|
|
||||||
advert_name: form.value.name.trim(),
|
|
||||||
broadcast_identity: form.value.broadcastIdentity,
|
broadcast_identity: form.value.broadcastIdentity,
|
||||||
...(rfParams ? { lora_radio_params: rfParams } : {}),
|
...(rfParams ? { lora_radio_params: rfParams } : {}),
|
||||||
})
|
})
|
||||||
saveDone.value = true
|
saveDone.value = true
|
||||||
setTimeout(() => { saveDone.value = false }, 5000)
|
setTimeout(() => { saveDone.value = false }, 3000)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
saveError.value = e instanceof Error ? e.message : 'Failed to save mesh settings'
|
saveError.value = e instanceof Error ? e.message : 'Failed to save mesh settings'
|
||||||
} finally {
|
} finally {
|
||||||
@ -289,7 +286,7 @@ async function saveSettings() {
|
|||||||
>
|
>
|
||||||
{{ saving ? 'Saving…' : 'Save Settings' }}
|
{{ saving ? 'Saving…' : 'Save Settings' }}
|
||||||
</button>
|
</button>
|
||||||
<span v-if="saveDone" class="text-xs text-green-400">Saved — applying to the radio now…</span>
|
<span v-if="saveDone" class="text-xs text-green-400">Saved — applies on next radio session</span>
|
||||||
<span v-if="saveError" class="text-xs text-red-400">{{ saveError }}</span>
|
<span v-if="saveError" class="text-xs text-red-400">{{ saveError }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -83,19 +83,6 @@
|
|||||||
.mesh-offgrid-active { border-color: rgba(251, 146, 60, 0.4) !important; color: #fb923c !important; }
|
.mesh-offgrid-active { border-color: rgba(251, 146, 60, 0.4) !important; color: #fb923c !important; }
|
||||||
.mesh-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
.mesh-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||||
.mesh-action-btn { flex: 1; padding: 8px 0; font-size: 0.8rem; }
|
.mesh-action-btn { flex: 1; padding: 8px 0; font-size: 0.8rem; }
|
||||||
.mesh-action-ok { color: #34d399; border-color: rgba(52, 211, 153, 0.4); }
|
|
||||||
.mesh-refresh-spinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
margin-right: 4px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
border: 2px solid rgba(251, 146, 60, 0.7);
|
|
||||||
border-top-color: transparent;
|
|
||||||
animation: mesh-refresh-spin 0.8s linear infinite;
|
|
||||||
vertical-align: -1px;
|
|
||||||
}
|
|
||||||
@keyframes mesh-refresh-spin { to { transform: rotate(360deg); } }
|
|
||||||
.mesh-peers-card { padding: 14px; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
.mesh-peers-card { padding: 14px; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||||
.mesh-peers-card .mesh-section-title { margin-bottom: 10px; flex-shrink: 0; }
|
.mesh-peers-card .mesh-section-title { margin-bottom: 10px; flex-shrink: 0; }
|
||||||
.mesh-peer-list { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; flex: 1; min-height: 0; }
|
.mesh-peer-list { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; flex: 1; min-height: 0; }
|
||||||
@ -374,35 +361,13 @@
|
|||||||
.mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; }
|
.mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; }
|
||||||
.mesh-typed-content-image-wrap { position: relative; display: inline-block; }
|
.mesh-typed-content-image-wrap { position: relative; display: inline-block; }
|
||||||
.mesh-typed-content-download-btn {
|
.mesh-typed-content-download-btn {
|
||||||
position: absolute; bottom: 8px; right: 8px;
|
position: absolute; bottom: 6px; right: 6px; width: 1.75rem; height: 1.75rem;
|
||||||
width: 2.25rem; height: 2.25rem; min-width: 2.25rem; flex-shrink: 0;
|
border-radius: 50%; border: 1px solid rgba(255,255,255,0.15);
|
||||||
border-radius: 50%; border: 1px solid rgba(255,255,255,0.18);
|
background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.85); font-size: 0.85rem;
|
||||||
background: rgba(10,10,14,0.55); color: rgba(255,255,255,0.9);
|
|
||||||
display: flex; align-items: center; justify-content: center; cursor: pointer;
|
display: flex; align-items: center; justify-content: center; cursor: pointer;
|
||||||
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
backdrop-filter: blur(6px);
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
|
||||||
transition: background 0.15s ease, transform 0.15s ease;
|
|
||||||
}
|
}
|
||||||
.mesh-typed-content-download-btn svg { width: 1.05rem; height: 1.05rem; }
|
.mesh-typed-content-download-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
|
||||||
.mesh-typed-content-download-btn:hover { background: rgba(251,146,60,0.35); color: #fff; transform: scale(1.06); }
|
|
||||||
.mesh-typed-content-download-btn:active { transform: scale(0.96); }
|
|
||||||
/* Pre-fetch "Download" pill under an incoming attachment. The generic .btn it
|
|
||||||
replaced collapsed to its text width inside the narrow mobile bubble and
|
|
||||||
looked squashed — this is a full-width glass pill in the house style. */
|
|
||||||
.mesh-typed-content-fetch-btn {
|
|
||||||
display: flex; align-items: center; justify-content: center; gap: 7px;
|
|
||||||
width: 100%; min-height: 2.4rem; padding: 8px 14px; margin-top: 2px;
|
|
||||||
border-radius: 12px; border: 1px solid rgba(255,255,255,0.14);
|
|
||||||
background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.9);
|
|
||||||
font-size: 0.82rem; font-weight: 500; cursor: pointer; white-space: nowrap;
|
|
||||||
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
|
||||||
transition: background 0.15s ease, border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
.mesh-typed-content-fetch-btn svg { width: 1rem; height: 1rem; flex-shrink: 0; }
|
|
||||||
.mesh-typed-content-fetch-btn:hover:not(:disabled) {
|
|
||||||
background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.4); color: #fff;
|
|
||||||
}
|
|
||||||
.mesh-typed-content-fetch-btn:disabled { opacity: 0.6; cursor: default; }
|
|
||||||
.mesh-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; }
|
.mesh-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; }
|
||||||
.mesh-tab { flex: 1; padding: 8px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.82rem; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 6px; }
|
.mesh-tab { flex: 1; padding: 8px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.82rem; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 6px; }
|
||||||
.mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
|
.mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
|
||||||
@ -581,27 +546,3 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
|
|||||||
.mesh-transport-meta { flex: 0 0 auto; font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
.mesh-transport-meta { flex: 0 0 auto; font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
||||||
.mesh-transport-cancel { margin-top: 4px; padding: 8px; background: transparent; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 0.85rem; }
|
.mesh-transport-cancel { margin-top: 4px; padding: 8px; background: transparent; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 0.85rem; }
|
||||||
.mesh-transport-cancel:hover { color: #fff; }
|
.mesh-transport-cancel:hover { color: #fff; }
|
||||||
|
|
||||||
/* Transport pills at the bottom of the image quality modal — pick LoRa vs
|
|
||||||
FIPS vs Tor for the outgoing image when the peer is federation-reachable. */
|
|
||||||
.mesh-image-transport-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
|
||||||
.mesh-image-transport-caption { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
|
||||||
.mesh-image-transport-pill { padding: 6px 12px; border-radius: 999px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.75); cursor: pointer; font-size: 0.8rem; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
|
|
||||||
.mesh-image-transport-pill:hover { background: rgba(255,255,255,0.1); }
|
|
||||||
.mesh-image-transport-pill.active { background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.7); color: #fff; }
|
|
||||||
|
|
||||||
/* Hop visualization modal + clickable transport pill */
|
|
||||||
.mesh-chat-transport-clickable { cursor: pointer; }
|
|
||||||
.mesh-chat-transport-clickable:hover { filter: brightness(1.4); }
|
|
||||||
.mesh-hopviz-chain { display: flex; align-items: center; gap: 10px; justify-content: center; padding: 14px 8px; flex-wrap: wrap; }
|
|
||||||
.mesh-hopviz-node { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 72px; }
|
|
||||||
.mesh-hopviz-icon { font-size: 1.8rem; }
|
|
||||||
.mesh-hopviz-name { font-size: 0.8rem; font-weight: 600; color: #fff; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.mesh-hopviz-link { flex: 1 1 auto; text-align: center; font-size: 0.8rem; color: rgba(255,255,255,0.75); border-top: 2px dashed rgba(251,146,60,0.5); padding-top: 6px; min-width: 120px; }
|
|
||||||
.mesh-hopviz-dots { margin-left: 6px; letter-spacing: 6px; color: rgba(251,146,60,0.9); }
|
|
||||||
.mesh-hopviz-signal { display: flex; align-items: center; gap: 10px; justify-content: center; flex-wrap: wrap; font-size: 0.8rem; color: rgba(255,255,255,0.75); padding: 4px 0; }
|
|
||||||
.mesh-hopviz-meta { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
|
||||||
.mesh-hopviz-note { flex-basis: 100%; text-align: center; font-size: 0.68rem; color: rgba(255,255,255,0.35); margin: 2px 0 0; }
|
|
||||||
|
|
||||||
/* Reaction dropdown inside the message action menu */
|
|
||||||
.mesh-chat-reaction-dropdown { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 4px; padding-top: 6px; }
|
|
||||||
|
|||||||
@ -113,7 +113,7 @@
|
|||||||
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
|
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
|
||||||
|
|
||||||
<div v-if="status.key_present && !status.service_active" class="flex gap-2 mt-auto pt-3 shrink-0">
|
<div v-if="status.key_present && !status.service_active" class="flex gap-2 mt-auto pt-3 shrink-0">
|
||||||
<button class="flex-1 min-h-[44px] px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" :disabled="installing" @click="installAndActivate">{{ installing ? 'Starting…' : 'Start' }}</button>
|
<button class="flex-1 min-h-[44px] px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" :disabled="installing" @click="installAndActivate">{{ installing ? 'Installing…' : 'Activate' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -121,7 +121,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useCachedResource } from '@/composables/useCachedResource'
|
|
||||||
import { safeClipboardWrite } from '@/views/web5/utils'
|
import { safeClipboardWrite } from '@/views/web5/utils'
|
||||||
import FipsSeedAnchorsCard from './FipsSeedAnchorsCard.vue'
|
import FipsSeedAnchorsCard from './FipsSeedAnchorsCard.vue'
|
||||||
|
|
||||||
@ -137,14 +136,7 @@ interface FipsStatus {
|
|||||||
anchor_connected?: boolean
|
anchor_connected?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shares `server.fips-summary` with the Local Network card's FIPS row, so
|
const status = ref<FipsStatus>({
|
||||||
// both paint from the same cache instantly on revisit and never disagree.
|
|
||||||
const statusRes = useCachedResource<FipsStatus>({
|
|
||||||
key: 'server.fips-summary',
|
|
||||||
ttlMs: 15_000,
|
|
||||||
fetcher: (signal) => rpcClient.call<FipsStatus>({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }),
|
|
||||||
})
|
|
||||||
const status = computed<FipsStatus>(() => statusRes.data.value ?? {
|
|
||||||
installed: false,
|
installed: false,
|
||||||
version: null,
|
version: null,
|
||||||
service_state: 'unknown',
|
service_state: 'unknown',
|
||||||
@ -207,15 +199,22 @@ function flash(msg: string, isError = false) {
|
|||||||
setTimeout(() => { statusMessage.value = '' }, 6000)
|
setTimeout(() => { statusMessage.value = '' }, 6000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
try {
|
||||||
|
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.status' })
|
||||||
|
} catch (e) {
|
||||||
|
if (import.meta.env.DEV) console.warn('fips.status failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function installAndActivate() {
|
async function installAndActivate() {
|
||||||
installing.value = true
|
installing.value = true
|
||||||
try {
|
try {
|
||||||
const next = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
|
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
|
||||||
statusRes.optimistic(() => next) // confirmed server state, not a guess
|
flash('FIPS installed and activated')
|
||||||
flash('FIPS started')
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : String(e)
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
flash(`Start failed: ${msg}`, true)
|
flash(`Install failed: ${msg}`, true)
|
||||||
} finally {
|
} finally {
|
||||||
installing.value = false
|
installing.value = false
|
||||||
}
|
}
|
||||||
@ -236,7 +235,7 @@ async function reconnectAnchor() {
|
|||||||
}>({ method: 'fips.reconnect', timeout: 60_000 })
|
}>({ method: 'fips.reconnect', timeout: 60_000 })
|
||||||
// Update the card with the post-reconnect status returned by the
|
// Update the card with the post-reconnect status returned by the
|
||||||
// backend — avoids an extra status fetch race.
|
// backend — avoids an extra status fetch race.
|
||||||
statusRes.optimistic((cur) => ({ ...(cur ?? status.value), ...res.after }))
|
status.value = { ...status.value, ...res.after }
|
||||||
if (res.recovered) {
|
if (res.recovered) {
|
||||||
flash('Anchor reconnected.')
|
flash('Anchor reconnected.')
|
||||||
} else if (res.likely_cause === 'connected') {
|
} else if (res.likely_cause === 'connected') {
|
||||||
@ -259,10 +258,8 @@ async function reconnectAnchor() {
|
|||||||
// stuck showing whatever anchor state existed at mount time forever.
|
// stuck showing whatever anchor state existed at mount time forever.
|
||||||
let statusInterval: ReturnType<typeof setInterval> | null = null
|
let statusInterval: ReturnType<typeof setInterval> | null = null
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
statusInterval = setInterval(() => {
|
loadStatus()
|
||||||
if (document.hidden) return
|
statusInterval = setInterval(loadStatus, 15000)
|
||||||
void statusRes.refresh()
|
|
||||||
}, 15000)
|
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (statusInterval) clearInterval(statusInterval)
|
if (statusInterval) clearInterval(statusInterval)
|
||||||
|
|||||||
@ -88,9 +88,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useCachedResource } from '@/composables/useCachedResource'
|
|
||||||
|
|
||||||
defineProps<{ closable?: boolean }>()
|
defineProps<{ closable?: boolean }>()
|
||||||
defineEmits<{ (e: 'close'): void }>()
|
defineEmits<{ (e: 'close'): void }>()
|
||||||
@ -108,16 +107,7 @@ interface ApplyResult {
|
|||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchorsRes = useCachedResource<SeedAnchor[]>({
|
const anchors = ref<SeedAnchor[]>([])
|
||||||
key: 'server.fips-seed-anchors',
|
|
||||||
fetcher: async (signal) => {
|
|
||||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({
|
|
||||||
method: 'fips.list-seed-anchors', signal, dedup: true, maxRetries: 1,
|
|
||||||
})
|
|
||||||
return res.seed_anchors
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const anchors = computed(() => anchorsRes.data.value ?? [])
|
|
||||||
const adding = ref(false)
|
const adding = ref(false)
|
||||||
const applying = ref(false)
|
const applying = ref(false)
|
||||||
const statusMessage = ref('')
|
const statusMessage = ref('')
|
||||||
@ -136,6 +126,15 @@ function flash(msg: string, isError = false) {
|
|||||||
setTimeout(() => { statusMessage.value = '' }, 6000)
|
setTimeout(() => { statusMessage.value = '' }, 6000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({ method: 'fips.list-seed-anchors' })
|
||||||
|
anchors.value = res.seed_anchors
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (import.meta.env.DEV) console.warn('fips.list-seed-anchors failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function addAnchor() {
|
async function addAnchor() {
|
||||||
if (!draft.npub.trim() || !draft.address.trim()) return
|
if (!draft.npub.trim() || !draft.address.trim()) return
|
||||||
adding.value = true
|
adding.value = true
|
||||||
@ -149,7 +148,7 @@ async function addAnchor() {
|
|||||||
label: draft.label.trim(),
|
label: draft.label.trim(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
anchorsRes.optimistic(() => res.seed_anchors) // authoritative post-add list
|
anchors.value = res.seed_anchors
|
||||||
draft.npub = ''
|
draft.npub = ''
|
||||||
draft.address = ''
|
draft.address = ''
|
||||||
draft.label = ''
|
draft.label = ''
|
||||||
@ -169,7 +168,7 @@ async function removeAnchor(npub: string) {
|
|||||||
method: 'fips.remove-seed-anchor',
|
method: 'fips.remove-seed-anchor',
|
||||||
params: { npub },
|
params: { npub },
|
||||||
})
|
})
|
||||||
anchorsRes.optimistic(() => res.seed_anchors)
|
anchors.value = res.seed_anchors
|
||||||
flash('Anchor removed.')
|
flash('Anchor removed.')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : String(e)
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
@ -190,4 +189,6 @@ async function applyAll() {
|
|||||||
applying.value = false
|
applying.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useResourcesStore } from '@/stores/resources'
|
|
||||||
import BackButton from '@/components/BackButton.vue'
|
import BackButton from '@/components/BackButton.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -74,15 +73,8 @@ interface ScannedNetwork {
|
|||||||
encryption: string
|
encryption: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = computed(() => statusEntry().data)
|
const status = ref<RouterStatus | null>(null)
|
||||||
// Router status is cached in the shared resources store so revisits paint
|
const loading = ref(true)
|
||||||
// the last-known state instantly while a fresh read runs behind it.
|
|
||||||
const resources = useResourcesStore()
|
|
||||||
const statusEntry = () => resources.entry<RouterStatus>('server.openwrt-status')
|
|
||||||
const loading = computed(() => {
|
|
||||||
const s = statusEntry().loadState
|
|
||||||
return s === 'loading' || s === 'refreshing' || (s === 'idle' && statusEntry().data === null)
|
|
||||||
})
|
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const host = ref('')
|
const host = ref('')
|
||||||
const sshUser = ref('root')
|
const sshUser = ref('root')
|
||||||
@ -123,25 +115,25 @@ const dhcpLimit = ref(150)
|
|||||||
const masqEnabled = ref(true)
|
const masqEnabled = ref(true)
|
||||||
|
|
||||||
async function load(params?: Record<string, string>) {
|
async function load(params?: Record<string, string>) {
|
||||||
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
await resources.refresh<RouterStatus>('server.openwrt-status', () =>
|
try {
|
||||||
rpcClient.call<RouterStatus>({
|
status.value = await rpcClient.call<RouterStatus>({
|
||||||
method: 'openwrt.get-status',
|
method: 'openwrt.get-status',
|
||||||
params: params ?? {},
|
params: params ?? {},
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
dedup: true,
|
})
|
||||||
maxRetries: 1,
|
|
||||||
}))
|
|
||||||
const err = statusEntry().error
|
|
||||||
if (err) {
|
|
||||||
if (err.includes('No router configured')) {
|
|
||||||
showConnectForm.value = true
|
|
||||||
} else {
|
|
||||||
error.value = err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showConnectForm.value = false
|
showConnectForm.value = false
|
||||||
if (params) connectedParams.value = params
|
if (params) connectedParams.value = params
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
if (msg.includes('No router configured')) {
|
||||||
|
showConnectForm.value = true
|
||||||
|
} else {
|
||||||
|
error.value = msg
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -362,20 +362,6 @@ init()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||||
<!-- v1.7.117-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.117-alpha</span>
|
|
||||||
<span class="text-xs text-white/40">July 27, 2026</span>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
|
||||||
<p>FIPS startup is more reliable on nodes that have the packaged fips.service instead of Archipelago's archipelago-fips.service. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.</p>
|
|
||||||
<p>App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.</p>
|
|
||||||
<p>Companion WebView safe-area handling now also moves fixed and sticky top bars below the phone status bar, including headers mounted after page load by single-page apps.</p>
|
|
||||||
<p>Public-source preparation now includes a Nostr Git hosting plan using ngit, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.</p>
|
|
||||||
<p>Node OS release notes for v1.7.117 are still open; add any installer, service, kernel, firewall, or package changes here before cutting the release.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- v1.7.116-alpha -->
|
<!-- v1.7.116-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
@ -93,9 +93,8 @@ let web5AnimationDone = false
|
|||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useCachedResource } from '@/composables/useCachedResource'
|
|
||||||
import { safeClipboardWrite } from './utils'
|
import { safeClipboardWrite } from './utils'
|
||||||
import type { ProfitsData, HwWalletDevice } from './types'
|
import type { ProfitsData, WalletTransaction, HwWalletDevice } from './types'
|
||||||
|
|
||||||
import Web5QuickActions from './Web5QuickActions.vue'
|
import Web5QuickActions from './Web5QuickActions.vue'
|
||||||
// import Web5Wallet from './Web5Wallet.vue' // hidden for now
|
// import Web5Wallet from './Web5Wallet.vue' // hidden for now
|
||||||
@ -137,13 +136,7 @@ function showToast(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Networking Profits ---
|
// --- Networking Profits ---
|
||||||
const profitsRes = useCachedResource<ProfitsData>({
|
const profitsBreakdown = ref<ProfitsData | null>(null)
|
||||||
key: 'web5.networking-profits',
|
|
||||||
fetcher: (signal) => rpcClient.call<ProfitsData>({ method: 'wallet.networking-profits', signal, dedup: true, maxRetries: 1 }),
|
|
||||||
})
|
|
||||||
const profitsBreakdown = computed<ProfitsData | null>(() =>
|
|
||||||
profitsRes.data.value
|
|
||||||
?? (profitsRes.error.value ? { total_sats: 0, content_sales_sats: 0, routing_fees_sats: 0 } : null))
|
|
||||||
const networkingProfitsDisplay = computed(() => {
|
const networkingProfitsDisplay = computed(() => {
|
||||||
if (!profitsBreakdown.value) return '...'
|
if (!profitsBreakdown.value) return '...'
|
||||||
const sats = profitsBreakdown.value.total_sats
|
const sats = profitsBreakdown.value.total_sats
|
||||||
@ -153,6 +146,15 @@ const networkingProfitsDisplay = computed(() => {
|
|||||||
return `\u20BF${btc.toFixed(8).replace(/0+$/, '').replace(/\.$/, '')}`
|
return `\u20BF${btc.toFixed(8).replace(/0+$/, '').replace(/\.$/, '')}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadNetworkingProfits() {
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<ProfitsData>({ method: 'wallet.networking-profits' })
|
||||||
|
profitsBreakdown.value = res
|
||||||
|
} catch {
|
||||||
|
profitsBreakdown.value = { total_sats: 0, content_sales_sats: 0, routing_fees_sats: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- DID State ---
|
// --- DID State ---
|
||||||
const storedDid = ref<string | null>(null)
|
const storedDid = ref<string | null>(null)
|
||||||
try {
|
try {
|
||||||
@ -288,35 +290,64 @@ async function copyDidDocument() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Wallet / LND Balances ---
|
// --- Wallet / LND Balances ---
|
||||||
// Cached: balances/transactions paint instantly on revisit and revalidate
|
const walletConnected = ref(false)
|
||||||
// behind the cached value; errors keep the last-known data.
|
|
||||||
const lndInfoRes = useCachedResource<{
|
|
||||||
balance_sats: number
|
|
||||||
channel_balance_sats: number
|
|
||||||
synced_to_chain: boolean
|
|
||||||
}>({
|
|
||||||
key: 'web5.lnd-info',
|
|
||||||
fetcher: (signal) => rpcClient.call({ method: 'lnd.getinfo', signal, dedup: true, maxRetries: 1 }),
|
|
||||||
})
|
|
||||||
// connectWallet() can still "disconnect" the (hidden) wallet card UI-side.
|
|
||||||
const walletManuallyDisconnected = ref(false)
|
|
||||||
const walletConnected = computed(() =>
|
|
||||||
!walletManuallyDisconnected.value && lndInfoRes.data.value !== null && !lndInfoRes.error.value)
|
|
||||||
const connectingWallet = ref(false)
|
const connectingWallet = ref(false)
|
||||||
// Ecash/transaction/balance display lives in the hidden wallet card — when it
|
const lndOnchainBalance = ref(0)
|
||||||
// returns, add cached resources for wallet.ecash-balance / lnd.gettransactions
|
const lndChannelBalance = ref(0)
|
||||||
// here rather than reviving the old eager loaders.
|
const walletError = ref('')
|
||||||
|
const ecashBalance = ref(0)
|
||||||
|
|
||||||
|
// Transactions — wallet card hidden, but loadTransactions still called for QuickActions walletConnected state
|
||||||
|
const walletTransactions = ref<WalletTransaction[]>([])
|
||||||
|
|
||||||
// Hardware wallets
|
// Hardware wallets
|
||||||
const detectedHwWallets = ref<HwWalletDevice[]>([])
|
const detectedHwWallets = ref<HwWalletDevice[]>([])
|
||||||
|
|
||||||
|
async function loadLndBalances() {
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{
|
||||||
|
balance_sats: number
|
||||||
|
channel_balance_sats: number
|
||||||
|
synced_to_chain: boolean
|
||||||
|
}>({ method: 'lnd.getinfo' })
|
||||||
|
lndOnchainBalance.value = res.balance_sats || 0
|
||||||
|
lndChannelBalance.value = res.channel_balance_sats || 0
|
||||||
|
walletConnected.value = true
|
||||||
|
walletError.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
walletConnected.value = false
|
||||||
|
lndOnchainBalance.value = 0
|
||||||
|
lndChannelBalance.value = 0
|
||||||
|
walletError.value = e instanceof Error ? e.message : 'Failed to load wallet balances'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEcashBalance() {
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ balance_sats: number; token_count: number }>({ method: 'wallet.ecash-balance' })
|
||||||
|
ecashBalance.value = res.balance_sats ?? 0
|
||||||
|
} catch {
|
||||||
|
// Keep last-known balance on a transient failure rather than flashing 0.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTransactions() {
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions' })
|
||||||
|
walletTransactions.value = res.transactions || []
|
||||||
|
walletError.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
walletTransactions.value = []
|
||||||
|
walletError.value = e instanceof Error ? e.message : 'Failed to load transactions'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function connectWallet() {
|
async function connectWallet() {
|
||||||
if (walletConnected.value) {
|
if (walletConnected.value) {
|
||||||
walletManuallyDisconnected.value = true
|
walletConnected.value = false
|
||||||
} else {
|
} else {
|
||||||
connectingWallet.value = true
|
connectingWallet.value = true
|
||||||
walletManuallyDisconnected.value = false
|
await loadLndBalances()
|
||||||
await lndInfoRes.refresh()
|
|
||||||
connectingWallet.value = false
|
connectingWallet.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -330,8 +361,13 @@ async function detectHardwareWallets() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-refresh wallet data every 30s while mounted (B5 will move this to
|
// function reloadBalances() { // wallet hidden
|
||||||
// WS-push invalidation; the store dedups overlapping refreshes).
|
// loadLndBalances()
|
||||||
|
// loadEcashBalance()
|
||||||
|
// loadTransactions()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Auto-refresh wallet data every 30s
|
||||||
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
|
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@ -356,15 +392,20 @@ onMounted(() => {
|
|||||||
// credentialsRef.value?.loadCredentials() // hidden for now
|
// credentialsRef.value?.loadCredentials() // hidden for now
|
||||||
// sharedContentRef.value?.loadContentItems() // hidden for now
|
// sharedContentRef.value?.loadContentItems() // hidden for now
|
||||||
|
|
||||||
// Wallet/profits resources fetch themselves on first use (and skip the
|
// Load local state data
|
||||||
// fetch entirely when the cached value is still fresh).
|
loadEcashBalance()
|
||||||
|
loadNetworkingProfits()
|
||||||
|
loadLndBalances()
|
||||||
|
loadTransactions()
|
||||||
detectHardwareWallets()
|
detectHardwareWallets()
|
||||||
|
|
||||||
// Shared content loaded by the component itself via expose
|
// Shared content loaded by the component itself via expose
|
||||||
// The SharedContent component manages its own loadContentItems
|
// The SharedContent component manages its own loadContentItems
|
||||||
|
|
||||||
walletRefreshInterval = setInterval(() => {
|
walletRefreshInterval = setInterval(() => {
|
||||||
void lndInfoRes.refresh()
|
loadLndBalances()
|
||||||
|
loadTransactions()
|
||||||
|
loadEcashBalance()
|
||||||
}, 30000)
|
}, 30000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -14,13 +14,12 @@ Security posture (see the plan's "most secure way" section):
|
|||||||
RPC (one JSON object per line, both directions):
|
RPC (one JSON object per line, both directions):
|
||||||
in : {"cmd":"send","dest_hash":"<hex16>","content":"…","title":"…","method":"direct|opportunistic"}
|
in : {"cmd":"send","dest_hash":"<hex16>","content":"…","title":"…","method":"direct|opportunistic"}
|
||||||
{"cmd":"announce"}
|
{"cmd":"announce"}
|
||||||
{"cmd":"set_name","name":"…"}
|
|
||||||
{"cmd":"status"}
|
{"cmd":"status"}
|
||||||
{"cmd":"send_resource","id":"<correlation>","dest_hash":"<hex16>","data_b64":"…"}
|
{"cmd":"send_resource","id":"<correlation>","dest_hash":"<hex16>","data_b64":"…"}
|
||||||
{"cmd":"shutdown"}
|
{"cmd":"shutdown"}
|
||||||
out: {"event":"ready","dest_hash":"<hex16>","display_name":"…"}
|
out: {"event":"ready","dest_hash":"<hex16>","display_name":"…"}
|
||||||
{"event":"recv","source_hash":"<hex16>","content":"…","title":"…","fields":{…},"app_data":"<hex>","rssi":n,"snr":n,"stamp":t}
|
{"event":"recv","source_hash":"<hex16>","content":"…","title":"…","fields":{…},"app_data":"<hex>","rssi":n,"snr":n,"stamp":t}
|
||||||
{"event":"announce","dest_hash":"<hex16>","app_data":"<hex>","display_name":"…"|null,"archy_blob":"ARCHY:2:…"|null}
|
{"event":"announce","dest_hash":"<hex16>","app_data":"<hex>"}
|
||||||
{"event":"delivered","dest_hash":"<hex16>","state":"delivered|failed","id":"<hex>"}
|
{"event":"delivered","dest_hash":"<hex16>","state":"delivered|failed","id":"<hex>"}
|
||||||
{"event":"status","connected":bool,"dest_hash":"<hex16>","interfaces":[…]}
|
{"event":"status","connected":bool,"dest_hash":"<hex16>","interfaces":[…]}
|
||||||
{"event":"resource_progress","id":"<correlation>","transferred":n,"total":n}
|
{"event":"resource_progress","id":"<correlation>","transferred":n,"total":n}
|
||||||
@ -228,47 +227,31 @@ class ReticulumDaemon:
|
|||||||
if self.delivery_destination is not None:
|
if self.delivery_destination is not None:
|
||||||
self.delivery_destination.announce(app_data=self._announce_app_data())
|
self.delivery_destination.announce(app_data=self._announce_app_data())
|
||||||
|
|
||||||
def _archy_identity_blob(self):
|
def _announce_app_data(self) -> bytes:
|
||||||
"""The ``ARCHY:2:{ed25519_hex}:{x25519_hex}`` identity string the Rust
|
"""Carry the Archy identity so peers bind this RNS destination onto the
|
||||||
side parses (``protocol::parse_identity_broadcast``) and binds via
|
existing contact, the same way a meshcore/Meshtastic identity advert does.
|
||||||
``handle_identity_received`` — so a Reticulum-carried identity merges
|
|
||||||
into the SAME conversation as the meshcore/Meshtastic/federation twins
|
Reuses the exact ``ARCHY:2:{ed25519_hex}:{x25519_hex}`` wire format the
|
||||||
of the same Archy node. The keys are the node's real Archipelago
|
Rust side already parses (``protocol::parse_identity_broadcast``) and
|
||||||
pubkeys (passed in by the Rust side) — NOT this daemon's
|
binds via ``handle_identity_received``/``bind_federation_twins`` — so a
|
||||||
internally-HKDF-derived RNS keys, which exist only to make the RNS
|
Reticulum-carried identity merges into the SAME conversation as the
|
||||||
destination hash deterministic. ``None`` when the pubkeys weren't
|
meshcore/Meshtastic/federation twins of the same Archy node, satisfying
|
||||||
supplied (dev/selftest run)."""
|
cross-protocol DM convergence. The keys are the node's real Archipelago
|
||||||
|
ed25519/x25519 pubkeys (passed in by the Rust side, which already has
|
||||||
|
them) — NOT this daemon's internally-HKDF-derived RNS keys, which exist
|
||||||
|
only to make the RNS destination hash deterministic and are never
|
||||||
|
themselves treated as an Archy identity.
|
||||||
|
|
||||||
|
Falls back to a plain display-name string (undetected as an identity
|
||||||
|
blob — no `ARCHY:2:` prefix) if the Archy pubkeys weren't supplied, e.g.
|
||||||
|
a dev/selftest run with no `--archy-ed-pubkey-hex`.
|
||||||
|
"""
|
||||||
if self.args.archy_ed_pubkey_hex and self.args.archy_x25519_pubkey_hex:
|
if self.args.archy_ed_pubkey_hex and self.args.archy_x25519_pubkey_hex:
|
||||||
return (
|
return (
|
||||||
f"ARCHY:2:{self.args.archy_ed_pubkey_hex}:"
|
f"ARCHY:2:{self.args.archy_ed_pubkey_hex}:"
|
||||||
f"{self.args.archy_x25519_pubkey_hex}"
|
f"{self.args.archy_x25519_pubkey_hex}"
|
||||||
).encode("ascii")
|
).encode("ascii")
|
||||||
return None
|
return (self.args.display_name or "").encode("utf-8")
|
||||||
|
|
||||||
def _announce_app_data(self) -> bytes:
|
|
||||||
"""LXMF-standard announce app_data — msgpack ``[display_name,
|
|
||||||
stamp_cost, supported_functionality]`` via the router, so Sideband/
|
|
||||||
NomadNet/MeshChat (and upgraded archy nodes) all see our real display
|
|
||||||
name — with the Archy identity blob appended as an EXTRA list element.
|
|
||||||
Stock clients only read the elements they know ([0]/[1]), so the blob
|
|
||||||
rides along invisibly instead of replacing the name the way the old
|
|
||||||
blob-only app_data did (which left every archy node nameless on RNS).
|
|
||||||
"""
|
|
||||||
import RNS.vendor.umsgpack as msgpack
|
|
||||||
app_data = self.router.get_announce_app_data(self.delivery_destination.hash)
|
|
||||||
blob = self._archy_identity_blob()
|
|
||||||
if blob is None:
|
|
||||||
return app_data
|
|
||||||
try:
|
|
||||||
peer_data = msgpack.unpackb(app_data)
|
|
||||||
if not isinstance(peer_data, list):
|
|
||||||
raise ValueError("unexpected announce app_data shape")
|
|
||||||
peer_data.append(blob)
|
|
||||||
return msgpack.packb(peer_data)
|
|
||||||
except Exception:
|
|
||||||
# Never let announce formatting kill announcing entirely — fall
|
|
||||||
# back to the legacy blob-only format (identity binding > name).
|
|
||||||
return blob
|
|
||||||
|
|
||||||
# ---- RNS-thread callbacks → asyncio ----
|
# ---- RNS-thread callbacks → asyncio ----
|
||||||
def _on_lxmf_delivery(self, message):
|
def _on_lxmf_delivery(self, message):
|
||||||
@ -345,15 +328,6 @@ class ReticulumDaemon:
|
|||||||
self._send(req)
|
self._send(req)
|
||||||
elif cmd == "announce":
|
elif cmd == "announce":
|
||||||
self.announce()
|
self.announce()
|
||||||
elif cmd == "set_name":
|
|
||||||
# Live rename: update the LXMF delivery destination's display name
|
|
||||||
# (what get_announce_app_data reads) and re-announce immediately so
|
|
||||||
# peers learn the new name without waiting for the next advert tick.
|
|
||||||
name = (req.get("name") or "").strip()
|
|
||||||
if name and self.delivery_destination is not None:
|
|
||||||
self.args.display_name = name
|
|
||||||
self.delivery_destination.display_name = name
|
|
||||||
self.announce()
|
|
||||||
elif cmd == "status":
|
elif cmd == "status":
|
||||||
self._broadcast(self._status())
|
self._broadcast(self._status())
|
||||||
elif cmd == "send_resource":
|
elif cmd == "send_resource":
|
||||||
@ -437,29 +411,13 @@ class ReticulumDaemon:
|
|||||||
identity, RNS.Destination.OUT, RNS.Destination.SINGLE,
|
identity, RNS.Destination.OUT, RNS.Destination.SINGLE,
|
||||||
"archy", "resource",
|
"archy", "resource",
|
||||||
)
|
)
|
||||||
# established_callback identifies us to the peer BEFORE any Resource
|
link = RNS.Link(out_dest)
|
||||||
# goes out: the receiving daemon attributes an inbound resource via
|
|
||||||
# link.get_remote_identity() (see _on_resource_received), which is
|
|
||||||
# None unless the initiator calls identify() — without this every
|
|
||||||
# transfer arrives with an empty source_hash and the Rust side can't
|
|
||||||
# route it to a contact (observed live 2026-07-28: 5KB image sent,
|
|
||||||
# concluded COMPLETE, never surfaced on the receiving node).
|
|
||||||
link = RNS.Link(
|
|
||||||
out_dest,
|
|
||||||
established_callback=lambda lk: self._on_out_link_established(
|
|
||||||
resource_hash, lk
|
|
||||||
),
|
|
||||||
)
|
|
||||||
link.set_link_closed_callback(
|
link.set_link_closed_callback(
|
||||||
lambda lk: self.links.pop(resource_hash, None)
|
lambda lk: self.links.pop(resource_hash, None)
|
||||||
)
|
)
|
||||||
self.links[resource_hash] = link
|
self.links[resource_hash] = link
|
||||||
return link, resource_hash
|
return link, resource_hash
|
||||||
|
|
||||||
def _on_out_link_established(self, resource_hash: bytes, link):
|
|
||||||
link.identify(self.identity)
|
|
||||||
self._flush_pending_resource_sends(resource_hash, link)
|
|
||||||
|
|
||||||
def _send_resource(self, req: dict):
|
def _send_resource(self, req: dict):
|
||||||
import RNS
|
import RNS
|
||||||
req_id = req.get("id", "")
|
req_id = req.get("id", "")
|
||||||
@ -479,14 +437,14 @@ class ReticulumDaemon:
|
|||||||
if link.status == RNS.Link.ACTIVE:
|
if link.status == RNS.Link.ACTIVE:
|
||||||
self._start_resource(link, data, req_id)
|
self._start_resource(link, data, req_id)
|
||||||
else:
|
else:
|
||||||
# Link is establishing — queue; the creation-time established
|
# Link is establishing — queue and flush from the established
|
||||||
# callback (_on_out_link_established) identifies us then flushes
|
# callback. set_link_established_callback only fires once per Link
|
||||||
# the queue. Re-check afterwards to close the race where the
|
# object (the cache is reused across sends), so re-set it here to
|
||||||
# link went ACTIVE between the status check and the append
|
# make sure THIS send's queue entry gets flushed too.
|
||||||
# (the callback fires once, on the RNS thread).
|
|
||||||
self.pending_resource_sends[resource_hash].append((data, req_id))
|
self.pending_resource_sends[resource_hash].append((data, req_id))
|
||||||
if link.status == RNS.Link.ACTIVE:
|
link.set_link_established_callback(
|
||||||
self._flush_pending_resource_sends(resource_hash, link)
|
lambda lk: self._flush_pending_resource_sends(resource_hash, lk)
|
||||||
|
)
|
||||||
|
|
||||||
def _flush_pending_resource_sends(self, resource_hash: bytes, link):
|
def _flush_pending_resource_sends(self, resource_hash: bytes, link):
|
||||||
import RNS
|
import RNS
|
||||||
@ -534,17 +492,10 @@ class ReticulumDaemon:
|
|||||||
RNS.Destination.hash(identity, "lxmf", "delivery").hex()
|
RNS.Destination.hash(identity, "lxmf", "delivery").hex()
|
||||||
if identity is not None else ""
|
if identity is not None else ""
|
||||||
)
|
)
|
||||||
# RNS hands a concluded Resource's data as a file-like object
|
|
||||||
# (BufferedReader over the assembled stream), not bytes —
|
|
||||||
# observed live 2026-07-28: b64encode raised "a bytes-like
|
|
||||||
# object is required" and every received transfer was lost.
|
|
||||||
data = resource.data
|
|
||||||
if hasattr(data, "read"):
|
|
||||||
data = data.read()
|
|
||||||
self._emit_threadsafe({
|
self._emit_threadsafe({
|
||||||
"event": "resource_recv",
|
"event": "resource_recv",
|
||||||
"source_hash": source_hash,
|
"source_hash": source_hash,
|
||||||
"data_b64": base64.b64encode(data).decode("ascii"),
|
"data_b64": base64.b64encode(resource.data).decode("ascii"),
|
||||||
})
|
})
|
||||||
except Exception as e: # never let a callback kill the RNS thread
|
except Exception as e: # never let a callback kill the RNS thread
|
||||||
self._emit_threadsafe({"event": "error", "where": "resource_recv",
|
self._emit_threadsafe({"event": "error", "where": "resource_recv",
|
||||||
@ -554,14 +505,7 @@ class ReticulumDaemon:
|
|||||||
sock_path = self.args.socket
|
sock_path = self.args.socket
|
||||||
if os.path.exists(sock_path):
|
if os.path.exists(sock_path):
|
||||||
os.unlink(sock_path)
|
os.unlink(sock_path)
|
||||||
# limit: asyncio's default per-line StreamReader cap is 64KiB, but
|
server = await asyncio.start_unix_server(self._handle_client, path=sock_path)
|
||||||
# send_resource requests carry the whole payload base64-encoded on one
|
|
||||||
# JSON line — a ~48KB+ attachment overflows the default and the raised
|
|
||||||
# LimitOverrunError tears down the RPC connection (the Rust side then
|
|
||||||
# sees "reticulum-daemon is gone" and restarts the whole session).
|
|
||||||
server = await asyncio.start_unix_server(
|
|
||||||
self._handle_client, path=sock_path, limit=16 * 1024 * 1024
|
|
||||||
)
|
|
||||||
os.chmod(sock_path, 0o600)
|
os.chmod(sock_path, 0o600)
|
||||||
self.announce()
|
self.announce()
|
||||||
async with server:
|
async with server:
|
||||||
@ -577,41 +521,10 @@ class _AnnounceHandler:
|
|||||||
self.receive_path_responses = True
|
self.receive_path_responses = True
|
||||||
|
|
||||||
def received_announce(self, destination_hash, announced_identity, app_data):
|
def received_announce(self, destination_hash, announced_identity, app_data):
|
||||||
# Decode what we can here (both the LXMF-standard display name and our
|
|
||||||
# appended ARCHY identity blob — see _announce_app_data) so the Rust
|
|
||||||
# side gets clean typed fields instead of re-implementing msgpack.
|
|
||||||
display_name = None
|
|
||||||
archy_blob = None
|
|
||||||
raw = app_data or b""
|
|
||||||
try:
|
|
||||||
import LXMF
|
|
||||||
display_name = LXMF.display_name_from_app_data(raw)
|
|
||||||
# A legacy blob-only announce is plain ascii, so LXMF's decoder
|
|
||||||
# returns the whole ARCHY identity blob as a "name" — drop it.
|
|
||||||
if display_name and display_name.startswith("ARCHY:"):
|
|
||||||
display_name = None
|
|
||||||
except Exception:
|
|
||||||
display_name = None
|
|
||||||
try:
|
|
||||||
if raw[:1] and ((0x90 <= raw[0] <= 0x9F) or raw[0] == 0xDC):
|
|
||||||
import RNS.vendor.umsgpack as msgpack
|
|
||||||
peer_data = msgpack.unpackb(raw)
|
|
||||||
if isinstance(peer_data, list):
|
|
||||||
for el in peer_data[3:]:
|
|
||||||
if isinstance(el, bytes) and el.startswith(b"ARCHY:"):
|
|
||||||
archy_blob = el.decode("ascii", "ignore")
|
|
||||||
break
|
|
||||||
elif raw.startswith(b"ARCHY:"):
|
|
||||||
# Legacy (pre-upgrade archy node): app_data IS the blob.
|
|
||||||
archy_blob = raw.decode("ascii", "ignore")
|
|
||||||
except Exception:
|
|
||||||
archy_blob = None
|
|
||||||
self.daemon._emit_threadsafe({
|
self.daemon._emit_threadsafe({
|
||||||
"event": "announce",
|
"event": "announce",
|
||||||
"dest_hash": destination_hash.hex(),
|
"dest_hash": destination_hash.hex(),
|
||||||
"app_data": raw.hex(),
|
"app_data": (app_data or b"").hex(),
|
||||||
"display_name": display_name,
|
|
||||||
"archy_blob": archy_blob,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@ -691,30 +604,8 @@ def main(argv=None) -> int:
|
|||||||
if args.selftest:
|
if args.selftest:
|
||||||
args.no_radio = True
|
args.no_radio = True
|
||||||
daemon.bring_up()
|
daemon.bring_up()
|
||||||
# Announce app_data round-trip: the LXMF-standard msgpack name must be
|
|
||||||
# decodable by stock clients AND (with archy keys set) the appended
|
|
||||||
# identity blob must survive as an extra list element — this is the
|
|
||||||
# exact wire contract the Rust announce handler and Sideband both
|
|
||||||
# depend on, so verify it here where there's a real router to build it.
|
|
||||||
import LXMF as _LXMF
|
|
||||||
import RNS.vendor.umsgpack as _msgpack
|
|
||||||
args.archy_ed_pubkey_hex = args.archy_ed_pubkey_hex or "ab" * 32
|
|
||||||
args.archy_x25519_pubkey_hex = args.archy_x25519_pubkey_hex or "cd" * 32
|
|
||||||
app_data = daemon._announce_app_data()
|
|
||||||
decoded_name = _LXMF.display_name_from_app_data(app_data)
|
|
||||||
assert decoded_name == args.display_name, (
|
|
||||||
f"announce name round-trip failed: {decoded_name!r} != {args.display_name!r}"
|
|
||||||
)
|
|
||||||
blob_elems = [e for e in _msgpack.unpackb(app_data)[3:]
|
|
||||||
if isinstance(e, bytes) and e.startswith(b"ARCHY:")]
|
|
||||||
assert blob_elems, "identity blob missing from announce app_data"
|
|
||||||
# Live rename: set_name must change what the next announce carries.
|
|
||||||
daemon.delivery_destination.display_name = "selftest-renamed"
|
|
||||||
renamed = _LXMF.display_name_from_app_data(daemon._announce_app_data())
|
|
||||||
assert renamed == "selftest-renamed", f"rename round-trip failed: {renamed!r}"
|
|
||||||
print(f"selftest ok — dest_hash={daemon.dest_hash_hex} "
|
print(f"selftest ok — dest_hash={daemon.dest_hash_hex} "
|
||||||
f"display_name={args.display_name!r} lxmf_router=up "
|
f"display_name={args.display_name!r} lxmf_router=up")
|
||||||
f"announce_app_data=verified set_name=verified")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
|||||||
@ -25,7 +25,7 @@ PATTERNS=(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Allowed files (config templates, docs, test fixtures)
|
# Allowed files (config templates, docs, test fixtures)
|
||||||
ALLOW_PATTERNS="test|e2e|mock|demo|example|Example|template|CLAUDE.md|deploy-config|\.md$|node_modules|dist|target|default\)|grep.*rpc|audit-secrets|startsWith|should start with"
|
ALLOW_PATTERNS="test|mock|example|template|CLAUDE.md|deploy-config|\.md$|node_modules|dist|target|default\)|grep.*rpc|audit-secrets"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
log "=== Secrets Audit ==="
|
log "=== Secrets Audit ==="
|
||||||
@ -34,7 +34,7 @@ main() {
|
|||||||
# 1. Check for .env files in version control
|
# 1. Check for .env files in version control
|
||||||
log "1. Checking for .env files in git..."
|
log "1. Checking for .env files in git..."
|
||||||
local env_files
|
local env_files
|
||||||
env_files=$(cd "$REPO_ROOT" && git ls-files | grep -E '(^|/)\.env($|[.])|(^|/)[^/]*\.env($|[.])' | grep -vE '(^|/)\.env\.example$|(^|/)[^/]*\.env\.example$' || echo "")
|
env_files=$(cd "$REPO_ROOT" && git ls-files '*.env' '.env*' 2>/dev/null || echo "")
|
||||||
if [ -z "$env_files" ]; then
|
if [ -z "$env_files" ]; then
|
||||||
pass "No .env files tracked in git"
|
pass "No .env files tracked in git"
|
||||||
else
|
else
|
||||||
@ -69,7 +69,7 @@ main() {
|
|||||||
if [ -n "$matches" ]; then
|
if [ -n "$matches" ]; then
|
||||||
# Filter out false positives (empty strings, variable declarations, etc.)
|
# Filter out false positives (empty strings, variable declarations, etc.)
|
||||||
local real_matches
|
local real_matches
|
||||||
real_matches=$(echo "$matches" | grep -vE '""|\x27\x27|None|null|undefined|TODO|placeholder|example|Option<|\$\{[A-Z0-9_]+:-\}|\$[A-Z0-9_]+|TestPassword|password123|entertoexit' || echo "")
|
real_matches=$(echo "$matches" | grep -vE '""|\x27\x27|None|null|undefined|TODO|placeholder|example|Option<' || echo "")
|
||||||
if [ -n "$real_matches" ]; then
|
if [ -n "$real_matches" ]; then
|
||||||
echo " WARNING: Pattern '$pattern' found:"
|
echo " WARNING: Pattern '$pattern' found:"
|
||||||
echo "$real_matches" | head -5 | sed 's/^/ /'
|
echo "$real_matches" | head -5 | sed 's/^/ /'
|
||||||
@ -96,7 +96,7 @@ main() {
|
|||||||
# 5. Check for credential files in repo
|
# 5. Check for credential files in repo
|
||||||
log "5. Checking for credential files..."
|
log "5. Checking for credential files..."
|
||||||
local cred_files
|
local cred_files
|
||||||
cred_files=$(cd "$REPO_ROOT" && git ls-files | grep -Ei '(\.pem$|\.key$|\.p12$|\.pfx$|\.jks$|\.keystore$|id_rsa|id_ed25519|macaroon)' | grep -vE '\.(rs|ts)$' || echo "")
|
cred_files=$(cd "$REPO_ROOT" && git ls-files '*.pem' '*.key' '*macaroon*' 2>/dev/null | grep -v '\.rs$' | grep -v '\.ts$' || echo "")
|
||||||
if [ -z "$cred_files" ]; then
|
if [ -z "$cred_files" ]; then
|
||||||
pass "No credential files tracked in git"
|
pass "No credential files tracked in git"
|
||||||
else
|
else
|
||||||
|
|||||||
@ -1,15 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Run Archipelago tests.
|
# Run all Archipelago tests: frontend (local) + backend (dev server via SSH).
|
||||||
#
|
# Exit 0 only if both pass.
|
||||||
# By default this runs frontend tests and local backend Rust tests. Set
|
|
||||||
# ARCHIPELAGO_SSH_HOST and ARCHIPELAGO_SSH_KEY to run backend tests on a Linux
|
|
||||||
# target instead.
|
|
||||||
#
|
#
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-}"
|
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||||
SSH_HOST="${ARCHIPELAGO_SSH_HOST:-}"
|
SSH_HOST="${ARCHIPELAGO_SSH_HOST:-archipelago@192.168.1.228}"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
@ -32,29 +29,34 @@ fi
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# --- Backend Tests ---
|
# --- Backend Tests (on dev server) ---
|
||||||
if [[ -n "$SSH_HOST" && -n "$SSH_KEY" ]]; then
|
echo "--- Backend Tests (dev server) ---"
|
||||||
echo "--- Backend Tests (Linux target: $SSH_HOST) ---"
|
|
||||||
echo "Syncing source to target..."
|
|
||||||
rsync -az --exclude 'target' --exclude 'node_modules' --exclude '.git' \
|
|
||||||
-e "ssh -i $SSH_KEY" \
|
|
||||||
"$PROJECT_DIR/core/" "$SSH_HOST:~/archy/core/" 2>&1
|
|
||||||
|
|
||||||
if ssh -i "$SSH_KEY" "$SSH_HOST" \
|
# Sync source to server
|
||||||
"source ~/.cargo/env && cd ~/archy/core && cargo test --all-features 2>&1"; then
|
echo "Syncing source to dev server..."
|
||||||
echo "✅ Backend tests PASSED"
|
rsync -az --exclude 'target' --exclude 'node_modules' --exclude '.git' \
|
||||||
BACKEND_OK=1
|
-e "ssh -i $SSH_KEY" \
|
||||||
else
|
"$PROJECT_DIR/core/" "$SSH_HOST:~/archy/core/" 2>&1
|
||||||
echo "❌ Backend tests FAILED"
|
|
||||||
fi
|
# Run tests on server
|
||||||
|
if ssh -i "$SSH_KEY" "$SSH_HOST" \
|
||||||
|
"source ~/.cargo/env && cd ~/archy/core && cargo test -p archipelago 2>&1"; then
|
||||||
|
echo "✅ Backend unit tests PASSED"
|
||||||
|
BACKEND_OK=1
|
||||||
else
|
else
|
||||||
echo "--- Backend Tests (local) ---"
|
echo "❌ Backend unit tests FAILED"
|
||||||
if (cd "$PROJECT_DIR/core" && cargo test --all-features 2>&1); then
|
fi
|
||||||
echo "✅ Backend tests PASSED"
|
|
||||||
BACKEND_OK=1
|
echo ""
|
||||||
else
|
|
||||||
echo "❌ Backend tests FAILED"
|
# --- Integration Tests ---
|
||||||
fi
|
echo "--- Integration Tests (dev server) ---"
|
||||||
|
if ssh -i "$SSH_KEY" "$SSH_HOST" \
|
||||||
|
"source ~/.cargo/env && cd ~/archy/core && cargo test --test rpc_integration 2>&1"; then
|
||||||
|
echo "✅ Integration tests PASSED"
|
||||||
|
else
|
||||||
|
echo "❌ Integration tests FAILED"
|
||||||
|
BACKEND_OK=0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@ -24,9 +24,8 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
|||||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||||
SSH_OPTS="-o StrictHostKeyChecking=no -i $SSH_KEY"
|
SSH_OPTS="-o StrictHostKeyChecking=no -i $SSH_KEY"
|
||||||
|
|
||||||
# Anthropic API key used by the AIUI Claude chat proxy. Keep this in the
|
# Anthropic API key — used by all servers for AIUI Claude chat
|
||||||
# caller's environment or scripts/deploy-config.sh; never commit live keys.
|
ANTHROPIC_API_KEY="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"
|
||||||
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
|
|
||||||
|
|
||||||
TARGET_HOST="$1"
|
TARGET_HOST="$1"
|
||||||
if [ -z "$TARGET_HOST" ]; then
|
if [ -z "$TARGET_HOST" ]; then
|
||||||
@ -35,12 +34,6 @@ if [ -z "$TARGET_HOST" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$ANTHROPIC_API_KEY" ]; then
|
|
||||||
echo "ERROR: ANTHROPIC_API_KEY must be set in the environment."
|
|
||||||
echo "Example: ANTHROPIC_API_KEY=<key> $0 $TARGET_HOST"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist"
|
AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist"
|
||||||
if [ ! -f "$AIUI_DIST/index.html" ]; then
|
if [ ! -f "$AIUI_DIST/index.html" ]; then
|
||||||
echo "ERROR: AIUI build not found at $AIUI_DIST"
|
echo "ERROR: AIUI build not found at $AIUI_DIST"
|
||||||
|
|||||||
@ -1,26 +1,25 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# validate-app-manifest.sh - validate an Archipelago app manifest.
|
# validate-app-manifest.sh — Validate a community-submitted app manifest
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage: ./scripts/validate-app-manifest.sh <manifest.yml>
|
||||||
# ./scripts/validate-app-manifest.sh [--repo-audit] apps/my-app/manifest.yml
|
|
||||||
#
|
#
|
||||||
# This intentionally mirrors the public app contract documented in
|
# Checks:
|
||||||
# docs/app-manifest-spec.md: manifests have a top-level `app:` block and are
|
# 1. Valid YAML syntax
|
||||||
# ultimately validated by the Rust parser in core/container/src/manifest.rs.
|
# 2. Required fields present (id, title, version, image, description)
|
||||||
# This script is the contributor-friendly preflight; the Rust parser remains
|
# 3. Image from trusted registry (docker.io, ghcr.io, quay.io)
|
||||||
# canonical.
|
# 4. No :latest tag (must pin specific version)
|
||||||
|
# 5. Resource limits specified (memory, cpu)
|
||||||
|
# 6. Security: no privileged mode, no host networking
|
||||||
|
# 7. No hardcoded secrets/passwords in environment
|
||||||
|
# 8. Port conflicts with existing apps
|
||||||
|
#
|
||||||
|
# Exit 0 = valid, Exit 1 = issues found
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_AUDIT=0
|
if [[ $# -lt 1 ]]; then
|
||||||
if [[ "${1:-}" == "--repo-audit" ]]; then
|
echo "Usage: $0 <manifest.yml>"
|
||||||
REPO_AUDIT=1
|
|
||||||
shift
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $# -ne 1 ]]; then
|
|
||||||
echo "Usage: $0 [--repo-audit] <manifest.yml>"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -31,235 +30,138 @@ WARN=0
|
|||||||
|
|
||||||
check() {
|
check() {
|
||||||
local desc="$1" result="$2"
|
local desc="$1" result="$2"
|
||||||
case "$result" in
|
if [[ "$result" == "pass" ]]; then
|
||||||
pass)
|
PASS=$((PASS + 1))
|
||||||
PASS=$((PASS + 1))
|
echo " PASS: $desc"
|
||||||
echo " PASS: $desc"
|
elif [[ "$result" == "warn" ]]; then
|
||||||
;;
|
WARN=$((WARN + 1))
|
||||||
warn)
|
echo " WARN: $desc"
|
||||||
WARN=$((WARN + 1))
|
else
|
||||||
echo " WARN: $desc"
|
FAIL=$((FAIL + 1))
|
||||||
;;
|
echo " FAIL: $desc"
|
||||||
*)
|
fi
|
||||||
FAIL=$((FAIL + 1))
|
|
||||||
echo " FAIL: $desc"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
yaml_eval() {
|
|
||||||
ruby -ryaml -e '
|
|
||||||
path, expr = ARGV
|
|
||||||
data = YAML.load_file(path)
|
|
||||||
app = data.is_a?(Hash) ? data["app"] : nil
|
|
||||||
abort "missing top-level app block" unless app.is_a?(Hash)
|
|
||||||
value = eval(expr)
|
|
||||||
case value
|
|
||||||
when Array
|
|
||||||
puts value.join("\n")
|
|
||||||
when Hash
|
|
||||||
puts value.to_a.map { |k, v| "#{k}=#{v}" }.join("\n")
|
|
||||||
when NilClass
|
|
||||||
puts ""
|
|
||||||
else
|
|
||||||
puts value
|
|
||||||
end
|
|
||||||
' "$MANIFEST" "$1"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "Validating: $MANIFEST"
|
echo "Validating: $MANIFEST"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# 1. File exists and is readable
|
||||||
if [[ ! -f "$MANIFEST" ]]; then
|
if [[ ! -f "$MANIFEST" ]]; then
|
||||||
echo " FAIL: File not found: $MANIFEST"
|
echo " FAIL: File not found: $MANIFEST"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
check "File exists" "pass"
|
check "File exists" "pass"
|
||||||
|
|
||||||
if ! ruby -ryaml -e 'data = YAML.load_file(ARGV[0]); exit(data.is_a?(Hash) && data["app"].is_a?(Hash) ? 0 : 1)' "$MANIFEST" 2>/dev/null; then
|
# 2. Valid YAML
|
||||||
check "Valid YAML with top-level app block" "fail"
|
if ! python3 -c "import yaml; yaml.safe_load(open('$MANIFEST'))" 2>/dev/null; then
|
||||||
echo ""
|
check "Valid YAML syntax" "fail"
|
||||||
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
|
echo " Cannot continue with invalid YAML"
|
||||||
echo "STATUS: REJECTED - fix failures before resubmitting"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
check "Valid YAML with top-level app block" "pass"
|
check "Valid YAML syntax" "pass"
|
||||||
|
|
||||||
APP_ID="$(yaml_eval 'app["id"]')"
|
# 3. Required fields
|
||||||
APP_NAME="$(yaml_eval 'app["name"]')"
|
CONTENT=$(python3 -c "
|
||||||
APP_VERSION="$(yaml_eval 'app["version"]')"
|
import yaml, json
|
||||||
APP_DESCRIPTION="$(yaml_eval 'app["description"]')"
|
with open('$MANIFEST') as f:
|
||||||
APP_INTERNAL="$(yaml_eval 'app["internal"]')"
|
d = yaml.safe_load(f)
|
||||||
IMAGE="$(yaml_eval '(app["container"] || {})["image"]')"
|
print(json.dumps(d))
|
||||||
BUILD_CONTEXT="$(yaml_eval '(((app["container"] || {})["build"] || {})["context"])')"
|
" 2>/dev/null)
|
||||||
BUILD_TAG="$(yaml_eval '(((app["container"] || {})["build"] || {})["tag"])')"
|
|
||||||
|
|
||||||
if [[ "$APP_ID" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
for field in id title version description; do
|
||||||
check "app.id is lowercase kebab-case ($APP_ID)" "pass"
|
val=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$field',''))" 2>/dev/null)
|
||||||
else
|
if [[ -n "$val" && "$val" != "None" ]]; then
|
||||||
check "app.id is lowercase kebab-case" "fail"
|
check "Required field '$field' present" "pass"
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$APP_NAME" ]]; then
|
|
||||||
check "app.name present" "pass"
|
|
||||||
else
|
|
||||||
check "app.name present" "fail"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$APP_VERSION" =~ [0-9] ]]; then
|
|
||||||
check "app.version present and contains a digit" "pass"
|
|
||||||
else
|
|
||||||
check "app.version present and contains a digit" "fail"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$APP_DESCRIPTION" ]]; then
|
|
||||||
check "app.description present" "pass"
|
|
||||||
else
|
|
||||||
check "app.description present" "warn"
|
|
||||||
fi
|
|
||||||
|
|
||||||
HAS_IMAGE=0
|
|
||||||
HAS_BUILD=0
|
|
||||||
[[ -n "$IMAGE" ]] && HAS_IMAGE=1
|
|
||||||
[[ -n "$BUILD_CONTEXT" || -n "$BUILD_TAG" ]] && HAS_BUILD=1
|
|
||||||
|
|
||||||
if [[ "$HAS_IMAGE" -eq 1 && "$HAS_BUILD" -eq 0 ]]; then
|
|
||||||
check "container.image specified" "pass"
|
|
||||||
elif [[ "$HAS_IMAGE" -eq 0 && "$HAS_BUILD" -eq 1 ]]; then
|
|
||||||
if [[ -n "$BUILD_CONTEXT" && -n "$BUILD_TAG" ]]; then
|
|
||||||
check "container.build specified with context and tag" "pass"
|
|
||||||
else
|
else
|
||||||
check "container.build requires context and tag" "fail"
|
check "Required field '$field' present" "fail"
|
||||||
fi
|
fi
|
||||||
else
|
done
|
||||||
check "exactly one of container.image or container.build specified" "fail"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$IMAGE" ]]; then
|
# 4. Image reference
|
||||||
|
IMAGE=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('image','') or d.get('docker_image','') or '')" 2>/dev/null)
|
||||||
|
if [[ -z "$IMAGE" || "$IMAGE" == "None" ]]; then
|
||||||
|
check "Container image specified" "fail"
|
||||||
|
else
|
||||||
|
check "Container image specified" "pass"
|
||||||
|
|
||||||
|
# Check trusted registry
|
||||||
TRUSTED=false
|
TRUSTED=false
|
||||||
for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "146.59.87.168:3000" "localhost/"; do
|
for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "146.59.87.168:3000"; do
|
||||||
if [[ "$IMAGE" == *"$reg"* ]]; then
|
if echo "$IMAGE" | grep -q "$reg"; then
|
||||||
TRUSTED=true
|
TRUSTED=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [[ "$TRUSTED" == "true" || "$IMAGE" != */* ]]; then
|
# Also allow short-form Docker Hub images (no registry prefix)
|
||||||
check "image registry is recognized" "pass"
|
if ! echo "$IMAGE" | grep -q "/"; then
|
||||||
|
TRUSTED=true # single-name images are Docker Hub official
|
||||||
|
fi
|
||||||
|
if [[ "$TRUSTED" == "true" ]]; then
|
||||||
|
check "Image from trusted registry" "pass"
|
||||||
else
|
else
|
||||||
check "image registry is not in the reviewed list ($IMAGE)" "warn"
|
check "Image from trusted registry ($IMAGE)" "warn"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$IMAGE" == *":latest" ]]; then
|
# Check no :latest
|
||||||
if [[ "$APP_INTERNAL" == "true" || "$IMAGE" == localhost/* ]]; then
|
if echo "$IMAGE" | grep -q ":latest$"; then
|
||||||
check "internal/local build uses :latest ($IMAGE)" "warn"
|
check "No :latest tag (pin specific version)" "fail"
|
||||||
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
|
elif ! echo "$IMAGE" | grep -q ":"; then
|
||||||
check "existing manifest uses :latest and must be pinned before public app submission ($IMAGE)" "warn"
|
check "No version tag specified (should pin version)" "warn"
|
||||||
else
|
|
||||||
check "image tag is pinned and not :latest ($IMAGE)" "fail"
|
|
||||||
fi
|
|
||||||
elif [[ "$IMAGE" != *:* ]]; then
|
|
||||||
check "image tag is explicit ($IMAGE)" "warn"
|
|
||||||
else
|
else
|
||||||
check "image tag is pinned" "pass"
|
check "Version tag pinned" "pass"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
MEMORY_LIMIT="$(yaml_eval '((app["resources"] || {})["memory_limit"] || (app["resources"] || {})["memory"])')"
|
# 5. Security checks
|
||||||
CPU_LIMIT="$(yaml_eval '((app["resources"] || {})["cpu_limit"] || (app["resources"] || {})["cpu"])')"
|
PRIVILEGED=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('privileged', False))" 2>/dev/null)
|
||||||
[[ -n "$MEMORY_LIMIT" ]] && check "resources.memory_limit specified ($MEMORY_LIMIT)" "pass" || check "resources.memory_limit specified" "warn"
|
if [[ "$PRIVILEGED" == "True" ]]; then
|
||||||
[[ -n "$CPU_LIMIT" ]] && check "resources.cpu_limit specified ($CPU_LIMIT)" "pass" || check "resources.cpu_limit specified" "warn"
|
check "No privileged mode" "fail"
|
||||||
|
|
||||||
READONLY_ROOT="$(yaml_eval '((app["security"] || {})["readonly_root"])')"
|
|
||||||
NO_NEW_PRIVS="$(yaml_eval '((app["security"] || {})["no_new_privileges"])')"
|
|
||||||
NETWORK_POLICY="$(yaml_eval '((app["security"] || {})["network_policy"])')"
|
|
||||||
CONTAINER_NETWORK="$(yaml_eval '((app["container"] || {})["network"])')"
|
|
||||||
|
|
||||||
if [[ "$READONLY_ROOT" == "true" || -z "$READONLY_ROOT" ]]; then
|
|
||||||
check "security.readonly_root true (explicit or Rust default)" "pass"
|
|
||||||
else
|
else
|
||||||
check "security.readonly_root true or explicitly justified" "warn"
|
check "No privileged mode" "pass"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$NO_NEW_PRIVS" == "true" || -z "$NO_NEW_PRIVS" ]]; then
|
HOST_NET=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('host_network', d.get('network_mode','')))" 2>/dev/null)
|
||||||
check "security.no_new_privileges true (explicit or Rust default)" "pass"
|
if [[ "$HOST_NET" == "host" ]]; then
|
||||||
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
|
check "No host networking" "fail"
|
||||||
check "existing manifest disables security.no_new_privileges and needs review" "warn"
|
|
||||||
else
|
else
|
||||||
check "security.no_new_privileges true" "fail"
|
check "No host networking" "pass"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$NETWORK_POLICY" == "isolated" || "$NETWORK_POLICY" == "bridge" || "$NETWORK_POLICY" == "host" || -z "$NETWORK_POLICY" ]]; then
|
# 6. Check for hardcoded secrets in env vars
|
||||||
check "security.network_policy valid" "pass"
|
ENV_VARS=$(echo "$CONTENT" | python3 -c "
|
||||||
|
import sys,json
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
env = d.get('environment', d.get('env', {}))
|
||||||
|
if isinstance(env, dict):
|
||||||
|
for k,v in env.items():
|
||||||
|
print(f'{k}={v}')
|
||||||
|
elif isinstance(env, list):
|
||||||
|
for e in env:
|
||||||
|
print(e)
|
||||||
|
" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
SECRET_PATTERNS="password|secret|api_key|private_key|token"
|
||||||
|
if echo "$ENV_VARS" | grep -iqE "$SECRET_PATTERNS"; then
|
||||||
|
check "No hardcoded secrets in environment" "warn"
|
||||||
else
|
else
|
||||||
check "security.network_policy valid" "fail"
|
check "No hardcoded secrets in environment" "pass"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$CONTAINER_NETWORK" == container:* || "$CONTAINER_NETWORK" == ns:* ]]; then
|
# 7. Memory limit
|
||||||
check "container.network does not share another namespace" "fail"
|
MEM=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('memory', d.get('mem_limit', d.get('resources',{}).get('memory',''))))" 2>/dev/null)
|
||||||
|
if [[ -n "$MEM" && "$MEM" != "None" && "$MEM" != "" ]]; then
|
||||||
|
check "Memory limit specified ($MEM)" "pass"
|
||||||
else
|
else
|
||||||
check "container.network does not share another namespace" "pass"
|
check "Memory limit specified" "warn"
|
||||||
fi
|
|
||||||
|
|
||||||
SECRET_ENV="$(yaml_eval '(app["environment"] || [])')"
|
|
||||||
if echo "$SECRET_ENV" | grep -iqE '^[A-Z0-9_]*(PASSWORD|PASS|SECRET|TOKEN|API_KEY|PRIVATE_KEY)[A-Z0-9_]*=.+$'; then
|
|
||||||
check "no hardcoded secret-like values in app.environment" "warn"
|
|
||||||
else
|
|
||||||
check "no hardcoded secret-like values in app.environment" "pass"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$APP_ID" && -n "$MANIFEST" ]]; then
|
|
||||||
EXPECTED_DIR="$(basename "$(dirname "$MANIFEST")")"
|
|
||||||
if [[ "$EXPECTED_DIR" == "$APP_ID" ]]; then
|
|
||||||
check "app.id matches directory name" "pass"
|
|
||||||
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
|
|
||||||
check "existing manifest app.id differs from directory name ($EXPECTED_DIR)" "warn"
|
|
||||||
else
|
|
||||||
check "app.id matches directory name ($EXPECTED_DIR)" "fail"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
PORT_CHECK="$(ruby -ryaml -e '
|
|
||||||
current = ARGV[0]
|
|
||||||
current_id = File.basename(File.dirname(current))
|
|
||||||
ports = {}
|
|
||||||
Dir.glob("apps/*/manifest.yml").sort.each do |path|
|
|
||||||
data = YAML.load_file(path)
|
|
||||||
app = data.is_a?(Hash) ? data["app"] : nil
|
|
||||||
next unless app.is_a?(Hash)
|
|
||||||
id = app["id"] || File.basename(File.dirname(path))
|
|
||||||
next if id == current_id
|
|
||||||
Array(app["ports"]).each do |p|
|
|
||||||
next unless p.is_a?(Hash)
|
|
||||||
proto = p["protocol"] || "tcp"
|
|
||||||
bind = p["bind"] || ""
|
|
||||||
host = p["host"]
|
|
||||||
ports[[host, proto, bind]] = id if host
|
|
||||||
end
|
|
||||||
end
|
|
||||||
data = YAML.load_file(current)
|
|
||||||
app = data["app"]
|
|
||||||
conflicts = []
|
|
||||||
Array(app["ports"]).each do |p|
|
|
||||||
next unless p.is_a?(Hash)
|
|
||||||
key = [p["host"], p["protocol"] || "tcp", p["bind"] || ""]
|
|
||||||
conflicts << "#{key[2].empty? ? "*" : key[2]}:#{key[0]}/#{key[1]} already used by #{ports[key]}" if ports.key?(key)
|
|
||||||
end
|
|
||||||
puts conflicts.join("\n")
|
|
||||||
' "$MANIFEST")"
|
|
||||||
if [[ -n "$PORT_CHECK" ]]; then
|
|
||||||
while IFS= read -r conflict; do
|
|
||||||
check "port conflict: $conflict" "warn"
|
|
||||||
done <<< "$PORT_CHECK"
|
|
||||||
else
|
|
||||||
check "no duplicate host port bindings" "pass"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
|
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
|
||||||
|
|
||||||
if [[ "$FAIL" -gt 0 ]]; then
|
if [[ "$FAIL" -gt 0 ]]; then
|
||||||
echo "STATUS: REJECTED - fix failures before resubmitting"
|
echo "STATUS: REJECTED — fix failures before resubmitting"
|
||||||
exit 1
|
exit 1
|
||||||
|
else
|
||||||
|
echo "STATUS: APPROVED (with $WARN warnings)"
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "STATUS: APPROVED (with $WARN warnings)"
|
|
||||||
|
|||||||
@ -1,85 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Mesh / Reticulum test suite — the "is the mesh stack healthy" gate.
|
|
||||||
#
|
|
||||||
# Three layers, cheapest first:
|
|
||||||
# 1. Rust unit tests (no hardware, ~2s once built)
|
|
||||||
# 2. Daemon selftest (full RNS+LXMF bring-up, no radio; also verifies
|
|
||||||
# the announce app_data wire contract + set_name)
|
|
||||||
# 3. Live-node assertions (optional; needs a running archipelago with a
|
|
||||||
# radio — set MESH_TEST_LIVE=1 MESH_TEST_PW=...)
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# tests/mesh/run-mesh-tests.sh # layers 1+2
|
|
||||||
# MESH_TEST_LIVE=1 MESH_TEST_PW='...' tests/mesh/run-mesh-tests.sh
|
|
||||||
# MESH_TEST_HOST=100.113.100.55 ... # live-test a remote node
|
|
||||||
set -u
|
|
||||||
cd "$(dirname "$0")/../.."
|
|
||||||
FAIL=0
|
|
||||||
ok() { echo "ok - $1"; }
|
|
||||||
bad() { echo "not ok - $1"; FAIL=1; }
|
|
||||||
|
|
||||||
# ── 1. Rust unit tests ────────────────────────────────────────────────
|
|
||||||
RUST_RESULTS=$(cd core && cargo test -p archipelago --bin archipelago mesh 2>&1 | grep "^test result:")
|
|
||||||
if [ -n "$RUST_RESULTS" ] && ! echo "$RUST_RESULTS" | grep -vq " 0 failed"; then
|
|
||||||
ok "rust mesh unit tests ($(echo "$RUST_RESULTS" | grep -o '[0-9]* passed' | head -1))"
|
|
||||||
else
|
|
||||||
bad "rust mesh unit tests"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 2. Reticulum daemon selftest (no radio) ───────────────────────────
|
|
||||||
TMP=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
head -c 32 /dev/urandom > "$TMP/key"
|
|
||||||
DAEMON=reticulum-daemon/.venv/bin/python
|
|
||||||
if [ -x "$DAEMON" ]; then
|
|
||||||
if "$DAEMON" reticulum-daemon/reticulum_daemon.py \
|
|
||||||
--identity-key "$TMP/key" --rns-config "$TMP/rns" \
|
|
||||||
--socket "$TMP/sock" --display-name "SelftestNode" --selftest 2>/dev/null \
|
|
||||||
| grep -q "announce_app_data=verified set_name=verified"; then
|
|
||||||
ok "daemon selftest (announce wire contract + set_name)"
|
|
||||||
else
|
|
||||||
bad "daemon selftest"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "skip - daemon selftest (no venv at $DAEMON)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 3. Live node assertions (opt-in) ──────────────────────────────────
|
|
||||||
if [ "${MESH_TEST_LIVE:-0}" = "1" ]; then
|
|
||||||
HOST="${MESH_TEST_HOST:-127.0.0.1}"
|
|
||||||
PW="${MESH_TEST_PW:?set MESH_TEST_PW}"
|
|
||||||
# Nodes differ: dev boxes serve plain http on :80, ISO installs https.
|
|
||||||
RPC=""
|
|
||||||
for base in "http://$HOST" "https://$HOST" "http://$HOST:5678"; do
|
|
||||||
code=$(curl -ksS -o /dev/null -w '%{http_code}' -m 5 -X POST "$base/rpc/v1" 2>/dev/null || true)
|
|
||||||
case "$code" in 000|"") continue ;; *) RPC="$base/rpc/v1"; break ;; esac
|
|
||||||
done
|
|
||||||
[ -n "$RPC" ] || { bad "live: no RPC endpoint reachable on $HOST"; echo FAIL; exit 1; }
|
|
||||||
JAR="$TMP/jar"
|
|
||||||
curl -ksS -c "$JAR" -H "Content-Type: application/json" \
|
|
||||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"auth.login\",\"params\":{\"password\":\"$PW\"},\"id\":1}" \
|
|
||||||
"$RPC" > "$TMP/login"
|
|
||||||
if grep -q '"error":null' "$TMP/login"; then ok "live: rpc login"; else bad "live: rpc login"; fi
|
|
||||||
call() {
|
|
||||||
local csrf; csrf=$(awk '/^[^#]/ && /csrf_token/ {print $7; exit}' "$JAR")
|
|
||||||
curl -ksS -b "$JAR" -c "$JAR" -H "Content-Type: application/json" \
|
|
||||||
-H "X-CSRF-Token: $csrf" \
|
|
||||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"$1\",\"params\":${2:-{\}},\"id\":2}" \
|
|
||||||
--max-time 60 "$RPC"
|
|
||||||
}
|
|
||||||
ST=$(call mesh.status)
|
|
||||||
echo "$ST" | grep -q '"device_connected":true' \
|
|
||||||
&& ok "live: radio connected ($(echo "$ST" | grep -o '"device_type":"[a-z]*"'))" \
|
|
||||||
|| bad "live: radio connected"
|
|
||||||
echo "$ST" | grep -q '"self_advert_name":"[^"]' \
|
|
||||||
&& ok "live: node has a mesh name" || bad "live: node has a mesh name"
|
|
||||||
call mesh.refresh | grep -q '"refreshed":true' \
|
|
||||||
&& ok "live: mesh.refresh" || bad "live: mesh.refresh"
|
|
||||||
call mesh.broadcast | grep -q '"broadcast":true' \
|
|
||||||
&& ok "live: mesh.broadcast" || bad "live: mesh.broadcast"
|
|
||||||
# No peer may ever display a raw identity blob as its name.
|
|
||||||
call mesh.peers | grep -q '"advert_name":"ARCHY:' \
|
|
||||||
&& bad "live: no ARCHY-blob peer names" || ok "live: no ARCHY-blob peer names"
|
|
||||||
fi
|
|
||||||
|
|
||||||
[ "$FAIL" = 0 ] && echo "PASS" || { echo "FAIL"; exit 1; }
|
|
||||||
Loading…
x
Reference in New Issue
Block a user