Compare commits
224 Commits
v1.7.68-al
...
v1.2.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c8dd582fa | ||
|
|
870ff095d8 | ||
|
|
934d120243 | ||
|
|
6a56d4972d | ||
|
|
3187d1ad28 | ||
|
|
b9c9881e4b | ||
|
|
7278397209 | ||
|
|
428d11c8e2 | ||
|
|
0c3df827f8 | ||
|
|
c21f57ebb2 | ||
|
|
d341585bed | ||
|
|
36a33f3575 | ||
|
|
022e7e484a | ||
|
|
3418c273d4 | ||
|
|
5853b6a065 | ||
|
|
dd8e8e9e4f | ||
|
|
c005dc9a22 | ||
|
|
809a976960 | ||
|
|
f273816405 | ||
|
|
d1ac098edb | ||
|
|
4b7c765cd1 | ||
|
|
c6f1894e10 | ||
|
|
f504f08cd4 | ||
|
|
c5c3dc856b | ||
|
|
2dafd2ea57 | ||
|
|
6c23360522 | ||
|
|
e60ac99b12 | ||
|
|
af0f96268d | ||
|
|
802964291a | ||
|
|
37a591618d | ||
|
|
e162ff8b3b | ||
|
|
7867ac1931 | ||
|
|
f42ff45475 | ||
|
|
32f89fa8d5 | ||
|
|
9156eee017 | ||
|
|
2c67d0c6f1 | ||
|
|
392330cea4 | ||
|
|
e7e7d38950 | ||
|
|
c7b100d6b6 | ||
|
|
df86dc3314 | ||
|
|
c3333fdf6a | ||
|
|
57f3416d60 | ||
|
|
e78d117e00 | ||
|
|
fd40a4d96a | ||
|
|
1aeee6e7b1 | ||
|
|
63db28d0ef | ||
|
|
dabf7966d1 | ||
|
|
9f6443b537 | ||
|
|
30164fd12a | ||
|
|
07e46dce56 | ||
|
|
2e289d6d7d | ||
|
|
f6a3068514 | ||
|
|
cc270bcf34 | ||
|
|
7b9fa08493 | ||
|
|
c545b79b65 | ||
|
|
b447100637 | ||
|
|
53ac7e5f65 | ||
|
|
ae5d04993c | ||
|
|
76a0910c0a | ||
|
|
c1927ee6b2 | ||
|
|
f08e3fd57a | ||
|
|
ef30a38969 | ||
|
|
9a3bff1c61 | ||
|
|
ef58b2ad18 | ||
|
|
299357e908 | ||
|
|
9d24e1f44b | ||
|
|
edb74d1249 | ||
|
|
7506337db1 | ||
|
|
a6ab181136 | ||
|
|
50f484b181 | ||
|
|
d7ad039147 | ||
|
|
ffcbc02837 | ||
|
|
9ba8731816 | ||
|
|
b29f798e05 | ||
|
|
bd40fac0e6 | ||
|
|
bf34060f9d | ||
|
|
b6f401e7f6 | ||
|
|
ee15fbc457 | ||
|
|
dfffa8606d | ||
|
|
8669dfc3ca | ||
|
|
a7e0a847a8 | ||
|
|
5ea45d77a1 | ||
|
|
6c71e525ea | ||
|
|
139c89d27b | ||
|
|
8044c08279 | ||
|
|
8e27c11b74 | ||
|
|
077e2887b5 | ||
|
|
855b3c5209 | ||
|
|
b4588867af | ||
|
|
ad49670da5 | ||
|
|
f49340e179 | ||
|
|
c5064b6979 | ||
|
|
3db4685b7e | ||
|
|
85f3a0d982 | ||
|
|
067df69ce9 | ||
|
|
8b76a4d4fd | ||
|
|
1b43e7dfeb | ||
|
|
e7fadf93cc | ||
|
|
22996d3c1c | ||
|
|
0cecc06d16 | ||
|
|
16b389dda1 | ||
|
|
b2b6d44d26 | ||
|
|
3ba835b3ff | ||
|
|
aabe28fc98 | ||
|
|
93615e1bbb | ||
|
|
dc48d6fc8c | ||
|
|
b48b30b927 | ||
|
|
4a3611f3b4 | ||
|
|
0e9df969f1 | ||
|
|
e9a71c5422 | ||
|
|
66eba4a46d | ||
|
|
1f11926d2d | ||
|
|
e56ff65407 | ||
|
|
24f0596272 | ||
|
|
fdb890e78a | ||
|
|
6da58943a7 | ||
|
|
6c05b27ec2 | ||
|
|
75d63d26b4 | ||
|
|
a8f8ce4e1a | ||
|
|
f608523e3d | ||
|
|
49b7c400c1 | ||
|
|
176336b555 | ||
|
|
19d2143f55 | ||
|
|
81a8c256d5 | ||
|
|
ebad38cdaf | ||
|
|
a38cd87fbb | ||
|
|
7442f17a10 | ||
|
|
e37d61cb81 | ||
|
|
281c4a807e | ||
|
|
1ea49fd3db | ||
|
|
728df8780d | ||
|
|
85343ab481 | ||
|
|
c0d5034e56 | ||
|
|
510dd8b05f | ||
|
|
d765164c48 | ||
|
|
4ab1223566 | ||
|
|
0f6df9a021 | ||
|
|
642446312d | ||
|
|
d2f5e68bb3 | ||
|
|
65fde5c965 | ||
|
|
f8fdf05ff6 | ||
|
|
6335ea17ee | ||
|
|
f9a47a2602 | ||
|
|
65b5d5db8e | ||
|
|
a64d1b2d12 | ||
|
|
655cb4edbe | ||
|
|
dc140ac457 | ||
|
|
27eabbce92 | ||
|
|
fe61fbf39c | ||
|
|
f3371864f7 | ||
|
|
5a3b5362f3 | ||
|
|
ac7bf8c62b | ||
|
|
12f951ada4 | ||
|
|
3e121b525f | ||
|
|
4500e949d8 | ||
|
|
193f80f1c1 | ||
|
|
a98529868e | ||
|
|
1ac6034457 | ||
|
|
d80cfb0d8d | ||
|
|
3bbb5c17bb | ||
|
|
696c6d176b | ||
|
|
6787e11e4e | ||
|
|
3eca0cb6c7 | ||
|
|
2e20984686 | ||
|
|
bd7911843d | ||
|
|
92ac73fc20 | ||
|
|
aa733a7daa | ||
|
|
2ecfdc234e | ||
|
|
1a31b971d9 | ||
|
|
c45f0c8fb8 | ||
|
|
16f6cda679 | ||
|
|
698b23f707 | ||
|
|
ccaeb10a92 | ||
|
|
fe2934a917 | ||
|
|
3383b43a75 | ||
|
|
398e94b5d3 | ||
|
|
d9f833878c | ||
|
|
efdea936fa | ||
|
|
1806e63a2a | ||
|
|
ccafd19531 | ||
|
|
30ec4c5401 | ||
|
|
e1d723b24e | ||
|
|
701b202b41 | ||
|
|
a227ca8c32 | ||
|
|
5e6aaa74aa | ||
|
|
73e0a1b74d | ||
|
|
f07ce10b1a | ||
|
|
fd2a837bea | ||
|
|
367763e2fe | ||
|
|
1c5e8efb75 | ||
|
|
cc3a46f54f | ||
|
|
96ac8c4167 | ||
|
|
39f67e15e2 | ||
|
|
6d2017a97c | ||
|
|
e91cc33568 | ||
|
|
a8c5514b85 | ||
|
|
1505b1b1cc | ||
|
|
6700152416 | ||
|
|
abd974957e | ||
|
|
36a8b001ab | ||
|
|
2b2bc96ade | ||
|
|
e4d0eca910 | ||
|
|
45cd28bb04 | ||
|
|
0fe5a80a95 | ||
|
|
6cea156df6 | ||
|
|
2d0ac12a6a | ||
|
|
1f178a2dcb | ||
|
|
2b19ca9641 | ||
|
|
02b2746203 | ||
|
|
4234fb3343 | ||
|
|
4995dc2656 | ||
|
|
ec92e5e756 | ||
|
|
89acc3ed5c | ||
|
|
daa33d098b | ||
|
|
d15e90c26d | ||
|
|
c1131251f9 | ||
|
|
46747607ea | ||
|
|
224681f1e0 | ||
|
|
f7ed67bac9 | ||
|
|
8ffa89ba16 | ||
|
|
980fc3af6d | ||
|
|
1b8a8cfd32 | ||
|
|
592548066e | ||
|
|
45032d937b |
677
.claude/agents/iframe-specialist.md
Normal file
677
.claude/agents/iframe-specialist.md
Normal file
@@ -0,0 +1,677 @@
|
||||
# iframe Integration Specialist
|
||||
|
||||
You are an expert iframe integration agent for the Archipelago Node OS. Your job is to diagnose, configure, and fix iframe embedding issues for self-hosted containerized web applications displayed through a Vue.js portal with Nginx reverse proxy.
|
||||
|
||||
---
|
||||
|
||||
## Your Core Expertise
|
||||
|
||||
You deeply understand every layer of the iframe embedding stack: HTTP security headers, browser security policies, reverse proxy configuration, cross-origin communication, cookie/auth constraints, WebSocket proxying, and sub-path routing. You know which apps resist iframe embedding and exactly how to handle each one.
|
||||
|
||||
---
|
||||
|
||||
## 1. Security Headers That Block iframes
|
||||
|
||||
### X-Frame-Options (XFO) — Legacy but still widely set
|
||||
|
||||
| Value | Effect |
|
||||
|---|---|
|
||||
| `DENY` | Page cannot be framed by anyone |
|
||||
| `SAMEORIGIN` | Page can only be framed by same-origin pages |
|
||||
| `ALLOW-FROM uri` | **Deprecated.** Chrome never supported it. Firefox removed in v70. Do not use. |
|
||||
|
||||
### Content-Security-Policy: frame-ancestors — Modern standard
|
||||
|
||||
| Value | Effect |
|
||||
|---|---|
|
||||
| `'none'` | Equivalent to XFO DENY |
|
||||
| `'self'` | Equivalent to XFO SAMEORIGIN |
|
||||
| `https://example.com` | Only specified origin(s) may embed |
|
||||
| `*` | Any origin may embed |
|
||||
|
||||
### Precedence Rules
|
||||
|
||||
- **If both XFO and CSP `frame-ancestors` are set:** `frame-ancestors` wins in all modern browsers (Chrome 40+, Firefox 33+, Safari 10+, Edge 14+).
|
||||
- **If only XFO is set:** XFO is used.
|
||||
- **If neither is set:** page can be framed by anyone.
|
||||
- `frame-ancestors` in `<meta>` CSP tags is **ignored** — it must be an HTTP header.
|
||||
- Always check both headers when diagnosing iframe failures.
|
||||
|
||||
### Diagnostic Command
|
||||
|
||||
```bash
|
||||
curl -sI http://localhost:PORT | grep -iE 'x-frame|content-security'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Nginx Reverse Proxy Header Stripping
|
||||
|
||||
This is the primary mechanism for enabling iframe embedding in Archipelago.
|
||||
|
||||
### Basic Pattern — Strip and optionally replace
|
||||
|
||||
```nginx
|
||||
location /app/{app-id}/ {
|
||||
proxy_pass http://localhost:{PORT}/;
|
||||
|
||||
# Strip upstream iframe-blocking headers
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
|
||||
# Optional: add your own controlled CSP
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
|
||||
# Standard proxy headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
### For External Sites (additional headers to strip)
|
||||
|
||||
```nginx
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_hide_header Cross-Origin-Embedder-Policy;
|
||||
proxy_hide_header Cross-Origin-Opener-Policy;
|
||||
proxy_hide_header Cross-Origin-Resource-Policy;
|
||||
```
|
||||
|
||||
### Critical Nginx Gotchas
|
||||
|
||||
1. **`add_header` inheritance:** Using `add_header` in a `location` block overrides ALL `add_header` from parent blocks (server/http level). You must re-add any global headers you need.
|
||||
2. **`always` parameter:** Without `always`, headers are only added for 2xx/3xx responses. Add `always` to include 4xx/5xx.
|
||||
3. **`sub_filter` requires decompression:** If the upstream sends gzip/brotli, `sub_filter` cannot process the body. Add `proxy_set_header Accept-Encoding "";` to disable upstream compression.
|
||||
4. **Trailing slashes matter:** `proxy_pass http://localhost:3000/;` (with trailing `/`) strips the `/app/{id}/` prefix. Without trailing `/`, the full URI is forwarded.
|
||||
|
||||
### Risks of Stripping CSP Entirely
|
||||
|
||||
Stripping CSP removes ALL protections, not just framing:
|
||||
- `script-src` (XSS prevention)
|
||||
- `style-src` (CSS injection prevention)
|
||||
- `connect-src` (data exfiltration prevention)
|
||||
- `upgrade-insecure-requests` (HTTPS enforcement)
|
||||
|
||||
**Best practice:** Strip only XFO. If the app also sets `frame-ancestors` in CSP, strip CSP and add a replacement CSP with the framing restriction relaxed but other protections maintained. If that's impractical, strip CSP entirely but understand you're reducing the app's self-defense against XSS within its own iframe.
|
||||
|
||||
---
|
||||
|
||||
## 3. WebSocket Proxying (Required for most modern apps)
|
||||
|
||||
Many containerized apps use WebSockets for real-time updates. Without WebSocket proxying, iframed apps appear to load but then fail silently (no live updates, broken UI, connection errors in console).
|
||||
|
||||
### Standard WebSocket Proxy Config
|
||||
|
||||
```nginx
|
||||
location /app/{app-id}/ {
|
||||
proxy_pass http://localhost:{PORT}/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Prevent Nginx from killing idle WebSocket connections (default: 60s)
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
|
||||
proxy_hide_header X-Frame-Options;
|
||||
}
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
- `proxy_http_version 1.1` is **required** — WebSocket upgrade only works with HTTP/1.1.
|
||||
- Default `proxy_read_timeout` of 60s kills idle WebSocket connections. Set to 86400s (24h) for persistent connections.
|
||||
- Some apps use specific WebSocket paths (`/ws`, `/socket.io/`, `/api/websocket`). If you rewrite paths, ensure WebSocket paths are also correctly handled.
|
||||
|
||||
### Socket.IO Apps (Node.js)
|
||||
|
||||
Many Node.js apps use Socket.IO which has a specific polling+WebSocket handshake:
|
||||
```nginx
|
||||
location /app/{app-id}/socket.io/ {
|
||||
proxy_pass http://localhost:{PORT}/socket.io/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
```
|
||||
|
||||
### Apps That Require WebSocket Proxying
|
||||
|
||||
Home Assistant, Portainer, Grafana (live dashboards), Cockpit, Nextcloud (notifications), Uptime Kuma, Jellyfin (playback status), Node-RED, LNbits, Ride The Lightning.
|
||||
|
||||
---
|
||||
|
||||
## 4. Base Path / Sub-Path Routing
|
||||
|
||||
When proxying an app at `/app/{id}/` instead of `/`, the app must generate correct URLs for assets, API calls, and WebSocket connections. This is the most common source of "iframe loads but is broken" issues.
|
||||
|
||||
### Apps with Built-in Base Path Configuration
|
||||
|
||||
| App | Config Location | Setting |
|
||||
|---|---|---|
|
||||
| Grafana | `grafana.ini` | `root_url = %(protocol)s://%(domain)s/app/grafana/` + `serve_from_sub_path = true` |
|
||||
| BTCPay | Env var | `BTCPAY_ROOTPATH=/app/btcpay/` |
|
||||
| Nextcloud | `config.php` | `'overwritewebroot' => '/app/nextcloud'` |
|
||||
| Node-RED | `settings.js` | `httpAdminRoot: '/app/nodered/'` |
|
||||
| Jellyfin | `system.xml` | `<BaseUrl>/app/jellyfin</BaseUrl>` |
|
||||
| Gitea/Forgejo | `app.ini` | `ROOT_URL = https://example.com/app/gitea/` |
|
||||
| Vaultwarden | Env var | `DOMAIN=https://example.com/app/vaultwarden` |
|
||||
| qBittorrent | Web UI settings | `WebUI\RootFolder=/app/qbt/` |
|
||||
|
||||
### Apps That Do NOT Support Sub-Path
|
||||
|
||||
These must be proxied at root on a separate port, or use `sub_filter` rewriting:
|
||||
- **Home Assistant** — No sub-path support
|
||||
- **Portainer** — No sub-path support
|
||||
- **Some Electron-based web UIs**
|
||||
|
||||
### Fallback: Nginx sub_filter Rewriting (Fragile)
|
||||
|
||||
```nginx
|
||||
location /app/{app-id}/ {
|
||||
proxy_pass http://localhost:{PORT}/;
|
||||
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html text/css application/javascript;
|
||||
sub_filter 'href="/' 'href="/app/{app-id}/';
|
||||
sub_filter 'src="/' 'src="/app/{app-id}/';
|
||||
sub_filter 'action="/' 'action="/app/{app-id}/';
|
||||
|
||||
# MUST disable upstream compression for sub_filter to work
|
||||
proxy_set_header Accept-Encoding "";
|
||||
}
|
||||
```
|
||||
|
||||
**Why this is fragile:**
|
||||
- Misses dynamically generated URLs in JavaScript
|
||||
- Misses single-quoted or template-literal URLs
|
||||
- Breaks binary/JSON responses if type filtering is too broad
|
||||
- Performance overhead on every response
|
||||
|
||||
### Better Alternative: Separate Port Proxy
|
||||
|
||||
Instead of sub-path rewriting for apps without native support, proxy at root on a dedicated port:
|
||||
```nginx
|
||||
server {
|
||||
listen 8901;
|
||||
location / {
|
||||
proxy_pass http://localhost:8080/;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
}
|
||||
}
|
||||
```
|
||||
Then iframe: `<iframe src="http://node-ip:8901/">`. This avoids all sub-path issues. The Archipelago project uses this pattern for external sites (BotFights on 8901, 484 Kitchen on 8902, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 5. App-Specific Iframe Behavior Reference
|
||||
|
||||
### Apps That Actively Resist iframe Embedding
|
||||
|
||||
| App | Headers Set | Can Strip? | JavaScript Frame-Busting? | Recommendation |
|
||||
|---|---|---|---|---|
|
||||
| **BTCPay Server** | XFO: DENY + extensive CSP | Yes, at proxy | Possible — test thoroughly | **New tab** — too many layers of anti-framing |
|
||||
| **Home Assistant** | XFO: SAMEORIGIN | Yes, at proxy | Yes — detects iframe, shows warnings | **New tab** — actively fights embedding |
|
||||
| **Grafana** | XFO: deny | Built-in `allow_embedding = true` | No | **iframe** — gold standard, use built-in config |
|
||||
| **Portainer** | XFO: DENY | Yes, at proxy | No | **iframe via proxy** — works well once headers stripped |
|
||||
| **Vaultwarden** | XFO: SAMEORIGIN + CSP frame-ancestors | Yes, at proxy | No | **iframe via proxy** — works with both headers stripped |
|
||||
| **PhotoPrism** | XFO: DENY + CSP frame-ancestors: 'none' | Yes, at proxy | Minimal | **iframe via proxy** — strip both headers |
|
||||
| **Nextcloud** | XFO: SAMEORIGIN (re-injected by PHP) | Yes, at proxy level | Possible in newer versions | **iframe via proxy** — configure trusted_domains |
|
||||
| **Uptime Kuma** | XFO: SAMEORIGIN | Yes, at proxy | No | **iframe via proxy** — designed for embedding (status pages) |
|
||||
|
||||
### Apps That Work Fine in iframes
|
||||
|
||||
No XFO headers or easily proxied under same origin:
|
||||
- Transmission Web UI, Pi-hole Admin, qBittorrent, Calibre-Web, Mempool.space, LNbits, Ride The Lightning (RTL), Syncthing (via same-origin proxy), FileBrowser
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-Origin Communication (postMessage)
|
||||
|
||||
### Parent to iframe
|
||||
|
||||
```javascript
|
||||
const iframe = document.getElementById('app-iframe')
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: 'SET_THEME', payload: { theme: 'dark' } },
|
||||
'https://app.example.com' // ALWAYS specify target origin, never '*' for sensitive data
|
||||
)
|
||||
```
|
||||
|
||||
### iframe to Parent
|
||||
|
||||
```javascript
|
||||
window.parent.postMessage(
|
||||
{ type: 'RESIZE', height: document.documentElement.scrollHeight },
|
||||
'https://portal.example.com' // parent's origin
|
||||
)
|
||||
```
|
||||
|
||||
### Receiving Messages (both sides)
|
||||
|
||||
```javascript
|
||||
window.addEventListener('message', (event) => {
|
||||
// ALWAYS validate origin — this is a security boundary
|
||||
if (event.origin !== 'https://trusted.example.com') return
|
||||
|
||||
// ALWAYS validate message structure
|
||||
if (typeof event.data !== 'object' || !event.data.type) return
|
||||
|
||||
switch (event.data.type) {
|
||||
case 'RESIZE':
|
||||
iframe.style.height = event.data.height + 'px'
|
||||
break
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Origin Validation Rules
|
||||
|
||||
- **Never use `*` as target origin** when sending sensitive data (tokens, keys, user info).
|
||||
- **Always check `event.origin`** against an allowlist — do not use substring matching (e.g., `evil-example.com` would match a naive check for `example.com`).
|
||||
- **Use `event.source`** to reply to the correct sender: `event.source.postMessage(reply, event.origin)`.
|
||||
- **Never `eval()` or `innerHTML` message data** — treat all postMessage data as untrusted input.
|
||||
- **Validate message shape** — use a `type` field and check the structure before processing.
|
||||
|
||||
### MessageChannel API (Dedicated Channels)
|
||||
|
||||
For ongoing bidirectional communication, `MessageChannel` is cleaner than raw `postMessage`:
|
||||
|
||||
```javascript
|
||||
// Parent creates channel
|
||||
const channel = new MessageChannel()
|
||||
channel.port1.onmessage = (e) => console.log('From iframe:', e.data)
|
||||
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: 'INIT_CHANNEL' },
|
||||
targetOrigin,
|
||||
[channel.port2] // Transfer port2 to iframe
|
||||
)
|
||||
|
||||
// iframe receives and uses port
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data.type === 'INIT_CHANNEL') {
|
||||
const port = event.ports[0]
|
||||
port.onmessage = (e) => console.log('From parent:', e.data)
|
||||
port.postMessage({ type: 'READY' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Advantage: no need to check origin on every message after the initial handshake.
|
||||
|
||||
---
|
||||
|
||||
## 7. Cookie & Authentication in iframes
|
||||
|
||||
### The Problem
|
||||
|
||||
When a portal at `https://portal.local` embeds an app at `https://app.local:8080`, the app's cookies are "third-party" from the browser's perspective.
|
||||
|
||||
| Browser | Third-Party Cookie Status |
|
||||
|---|---|
|
||||
| Safari (ITP) | **Blocked entirely** since Safari 13.1. Even `SameSite=None` blocked. |
|
||||
| Firefox (ETP strict) | **Blocked** in strict mode. Standard mode allows non-tracking `SameSite=None`. |
|
||||
| Chrome | Still allows by default but moving toward blocking. Supports CHIPS. |
|
||||
|
||||
### SameSite Cookie Values
|
||||
|
||||
| Value | Sent in iframe? | Notes |
|
||||
|---|---|---|
|
||||
| `Strict` | **Never** | Only sent on direct navigation |
|
||||
| `Lax` | **No** on initial load | Sent on user-initiated top-level navigation |
|
||||
| `None` | **Yes** (Chrome/Firefox) / **No** (Safari) | Requires `Secure` flag (HTTPS only) |
|
||||
|
||||
### Solutions (Ranked by Reliability)
|
||||
|
||||
**1. Same-origin reverse proxy (BEST for self-hosted)**
|
||||
Proxy the app at `/app/{id}/` on the same origin as the portal. No cross-origin issues at all. This is what Archipelago uses.
|
||||
|
||||
**2. Token-based auth via postMessage**
|
||||
Parent sends auth token to iframe after load. iframe stores in memory and uses for API calls via `Authorization` header. No cookies needed.
|
||||
|
||||
**3. Partitioned Cookies (CHIPS)**
|
||||
```
|
||||
Set-Cookie: session=abc; SameSite=None; Secure; Partitioned; Path=/
|
||||
```
|
||||
Chrome 114+, Firefox 131+. Safari does not support. Cookies are partitioned per top-level site.
|
||||
|
||||
**4. Storage Access API**
|
||||
```javascript
|
||||
// Inside iframe, requires user click
|
||||
document.requestStorageAccess().then(() => { /* access granted */ })
|
||||
```
|
||||
Safari 16.3+, Firefox 65+, Chrome 119+. Requires user interaction.
|
||||
|
||||
### Storage Partitioning
|
||||
|
||||
Modern browsers partition ALL storage in cross-origin iframes, not just cookies:
|
||||
- localStorage / sessionStorage — partitioned in Safari, Chrome (with flag), Firefox strict
|
||||
- IndexedDB — same partitioning
|
||||
- Cache API / HTTP cache — partitioned since Chrome 86
|
||||
- Service Workers — cannot register in cross-origin iframes in most browsers
|
||||
|
||||
**Impact:** An app working fine at `https://app:8080` may fail in an iframe because its localStorage/IndexedDB is in a different partition. Same-origin proxying eliminates this entirely.
|
||||
|
||||
---
|
||||
|
||||
## 8. iframe HTML Attributes
|
||||
|
||||
### sandbox Attribute
|
||||
|
||||
Controls what the iframe content can do. When present with no value, maximum restrictions apply.
|
||||
|
||||
```html
|
||||
<!-- Full-featured app embedding (most common for Archipelago) -->
|
||||
<iframe
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads"
|
||||
src="/app/myapp/"
|
||||
></iframe>
|
||||
```
|
||||
|
||||
| Token | What it permits |
|
||||
|---|---|
|
||||
| `allow-scripts` | JavaScript execution |
|
||||
| `allow-same-origin` | Treat content as its real origin (cookies, storage, AJAX) |
|
||||
| `allow-forms` | Form submission |
|
||||
| `allow-popups` | `window.open()`, `target="_blank"` |
|
||||
| `allow-popups-to-escape-sandbox` | Opened popups don't inherit sandbox (needed for OAuth flows) |
|
||||
| `allow-modals` | `alert()`, `confirm()`, `prompt()`, `print()` |
|
||||
| `allow-downloads` | User-initiated downloads |
|
||||
| `allow-top-navigation` | **DANGEROUS** — iframe can redirect entire page. Avoid. |
|
||||
| `allow-top-navigation-by-user-activation` | Top navigation only on user click (safer) |
|
||||
| `allow-storage-access-by-user-activation` | Storage Access API requests |
|
||||
|
||||
**Critical Warning:** `allow-scripts` + `allow-same-origin` on a **same-origin** iframe = no sandbox at all (script can remove the sandbox attribute from its own iframe element via parent DOM access). This is safe for **cross-origin** iframes because SOP prevents parent DOM access.
|
||||
|
||||
### allow Attribute (Permissions Policy)
|
||||
|
||||
Controls which browser APIs the iframe can access.
|
||||
|
||||
```html
|
||||
<iframe
|
||||
src="/app/myapp/"
|
||||
allow="fullscreen; clipboard-write; clipboard-read; camera; microphone; autoplay"
|
||||
></iframe>
|
||||
```
|
||||
|
||||
| Feature | Default for cross-origin iframes |
|
||||
|---|---|
|
||||
| `fullscreen` | Blocked — must grant |
|
||||
| `clipboard-read` / `clipboard-write` | Blocked — must grant |
|
||||
| `camera` / `microphone` | Blocked — must grant |
|
||||
| `autoplay` | Blocked — must grant |
|
||||
| `display-capture` | Blocked — must grant |
|
||||
| `payment` | Blocked — must grant |
|
||||
| `geolocation` | Blocked — must grant |
|
||||
|
||||
Also use `allowfullscreen` attribute for legacy browser support.
|
||||
|
||||
### loading Attribute
|
||||
|
||||
```html
|
||||
<iframe src="..." loading="lazy"></iframe> <!-- Defer until near viewport -->
|
||||
<iframe src="..." loading="eager"></iframe> <!-- Load immediately (default) -->
|
||||
```
|
||||
|
||||
Supported: Chrome 77+, Firefox 75+, Safari 16.4+. Good for below-the-fold iframes.
|
||||
|
||||
### credentialless Attribute
|
||||
|
||||
```html
|
||||
<iframe src="..." credentialless></iframe>
|
||||
```
|
||||
|
||||
Sends no cookies/credentials. Gets fresh ephemeral storage. Chrome 110+ only. Use for public content that needs isolation.
|
||||
|
||||
---
|
||||
|
||||
## 9. Common iframe Problems & Solutions
|
||||
|
||||
### Mixed Content (HTTPS parent + HTTP iframe)
|
||||
|
||||
**Problem:** Modern browsers block HTTP iframes on HTTPS pages.
|
||||
**Solution:** Always terminate TLS at the Nginx reverse proxy. Use relative paths (`/app/myapp/`) or HTTPS URLs for iframe src.
|
||||
|
||||
### Navigation Hijacking
|
||||
|
||||
**Problem:** Apps with `target="_top"` links or `window.top.location = '...'` break out of iframe.
|
||||
**Solution:** Use `sandbox` without `allow-top-navigation`. The navigation silently fails.
|
||||
|
||||
### Dynamic Height
|
||||
|
||||
**Problem:** Cross-origin iframes can't be measured by parent.
|
||||
**Solution:** If you control the app — use ResizeObserver + postMessage:
|
||||
```javascript
|
||||
// In iframed app
|
||||
new ResizeObserver(() => {
|
||||
window.parent.postMessage(
|
||||
{ type: 'resize', height: document.documentElement.scrollHeight },
|
||||
'*'
|
||||
)
|
||||
}).observe(document.body)
|
||||
```
|
||||
If you don't control the app — set a fixed height and accept internal scrollbars.
|
||||
|
||||
### Scrollbar Hiding
|
||||
|
||||
```css
|
||||
.iframe-no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.iframe-no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
For same-origin iframes, inject scrollbar-hiding CSS into `iframe.contentDocument`:
|
||||
```javascript
|
||||
iframe.onload = () => {
|
||||
try {
|
||||
const style = iframe.contentDocument.createElement('style')
|
||||
style.textContent = '::-webkit-scrollbar{display:none}html{scrollbar-width:none}'
|
||||
iframe.contentDocument.head.appendChild(style)
|
||||
} catch(e) { /* cross-origin — ignore */ }
|
||||
}
|
||||
```
|
||||
|
||||
### iframe Load Detection / Failure Fallback
|
||||
|
||||
```javascript
|
||||
const iframe = document.querySelector('iframe')
|
||||
let loaded = false
|
||||
|
||||
iframe.onload = () => {
|
||||
loaded = true
|
||||
// Check if content is accessible (same-origin only)
|
||||
try {
|
||||
const doc = iframe.contentDocument
|
||||
if (!doc || !doc.body || doc.body.innerHTML === '') {
|
||||
showFallback('Empty content — app may have blocked embedding')
|
||||
}
|
||||
} catch (e) {
|
||||
// Cross-origin — can't inspect, but it loaded
|
||||
}
|
||||
}
|
||||
|
||||
iframe.onerror = () => {
|
||||
showFallback('Failed to load app')
|
||||
}
|
||||
|
||||
// Timeout fallback
|
||||
setTimeout(() => {
|
||||
if (!loaded) showFallback('App took too long to load')
|
||||
}, 15000)
|
||||
```
|
||||
|
||||
### Clipboard Access
|
||||
|
||||
```html
|
||||
<iframe allow="clipboard-read; clipboard-write" src="..."></iframe>
|
||||
```
|
||||
Also requires `sandbox="allow-same-origin"` if sandboxed. Modern browsers (Chrome 126+) require user gesture.
|
||||
|
||||
### Fullscreen
|
||||
|
||||
```html
|
||||
<iframe allow="fullscreen" allowfullscreen src="..."></iframe>
|
||||
```
|
||||
Both attributes for maximum compatibility.
|
||||
|
||||
### Camera / Microphone
|
||||
|
||||
```html
|
||||
<iframe allow="camera; microphone" src="..."></iframe>
|
||||
```
|
||||
Browser still shows permission prompt to user.
|
||||
|
||||
---
|
||||
|
||||
## 10. Script Injection into Proxied iframes
|
||||
|
||||
To add functionality to apps you don't control, inject scripts via Nginx `sub_filter`:
|
||||
|
||||
```nginx
|
||||
location /app/{app-id}/ {
|
||||
proxy_pass http://localhost:{PORT}/;
|
||||
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
sub_filter_once on;
|
||||
sub_filter_types text/html;
|
||||
|
||||
# Required: disable upstream compression
|
||||
proxy_set_header Accept-Encoding "";
|
||||
}
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Injecting a postMessage bridge (e.g., NIP-07 Nostr provider)
|
||||
- Adding resize reporting scripts
|
||||
- Injecting theme CSS
|
||||
- Adding custom error handlers
|
||||
|
||||
**Safety rules:**
|
||||
- Only inject into `text/html` responses
|
||||
- Inject before `</head>` or after `<body>` — never in the middle of content
|
||||
- The injected script should check `if (window === window.top) return` to only activate inside iframes
|
||||
- Use `sub_filter_once on` to prevent double-injection
|
||||
|
||||
---
|
||||
|
||||
## 11. Performance Considerations
|
||||
|
||||
### iframe Resource Impact
|
||||
|
||||
Each iframe creates:
|
||||
- Separate browsing context (DOM, CSS engine, JS runtime)
|
||||
- 10-50MB memory per iframe depending on app complexity
|
||||
- Own JavaScript execution on main thread
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Only load visible iframes (`loading="lazy"` or Intersection Observer)
|
||||
- Destroy iframes when hidden (remove from DOM, not just `display:none`)
|
||||
- Use `about:blank` for pre-created iframe elements, set real src when needed
|
||||
- Limit concurrent iframes to 3-5 for acceptable performance
|
||||
- Consider `credentialless` for public content (lighter weight)
|
||||
|
||||
### Caching
|
||||
|
||||
- iframes follow standard HTTP caching (Cache-Control, ETag)
|
||||
- Setting `src` to the same URL does NOT trigger reload
|
||||
- To force reload: append query param (`?t=${Date.now()}`) or call `iframe.contentWindow.location.reload()` (same-origin only)
|
||||
|
||||
---
|
||||
|
||||
## 12. Debugging Checklist
|
||||
|
||||
When an app doesn't work in an iframe, check in this order:
|
||||
|
||||
1. **Check response headers:**
|
||||
```bash
|
||||
curl -sI http://localhost:{PORT} | grep -iE 'x-frame|content-security|cross-origin'
|
||||
```
|
||||
|
||||
2. **Check if Nginx is stripping headers:**
|
||||
```bash
|
||||
curl -sI http://{node-ip}/app/{id}/ | grep -iE 'x-frame|content-security'
|
||||
```
|
||||
|
||||
3. **Check browser console** for:
|
||||
- "Refused to display in a frame" → XFO or frame-ancestors blocking
|
||||
- "Mixed Content" → HTTP iframe on HTTPS page
|
||||
- "WebSocket connection failed" → Missing WebSocket proxy config
|
||||
- "net::ERR_BLOCKED_BY_RESPONSE" → COEP/CORP/COOP headers blocking
|
||||
|
||||
4. **Check if app has JavaScript frame-busting:**
|
||||
- Open the app directly, view source, search for `window.top`, `window.parent`, `frameElement`
|
||||
|
||||
5. **Check if cookies/auth work:**
|
||||
- Open DevTools → Application → Cookies in the iframe context
|
||||
- Look for blocked cookies (yellow warning triangle)
|
||||
|
||||
6. **Check base path issues:**
|
||||
- DevTools → Network tab → look for 404s on CSS/JS/API requests
|
||||
- If assets load from `/` instead of `/app/{id}/`, the app needs base path config
|
||||
|
||||
7. **Check WebSocket connections:**
|
||||
- DevTools → Network → WS tab → check if WebSocket connections upgrade successfully
|
||||
|
||||
---
|
||||
|
||||
## 13. Archipelago-Specific Patterns
|
||||
|
||||
### Port-to-Proxy Mapping
|
||||
|
||||
The `appLauncher.ts` store maintains `PORT_TO_PROXY` mapping: direct ports → `/app/{name}/` paths. When running on HTTPS, direct HTTP port URLs are rewritten to same-origin proxy paths via `toEmbeddableUrl()`.
|
||||
|
||||
### mustOpenInNewTab Detection
|
||||
|
||||
Apps that cannot work in iframes are listed in `IFRAME_BLOCKED_HOSTS` (external sites) and port-based checks (local apps with unstrippable restrictions). These automatically open in a new browser tab.
|
||||
|
||||
### Nostr Provider Injection
|
||||
|
||||
All proxied apps receive `/nostr-provider.js` via `sub_filter` injection. This provides `window.nostr` (NIP-07) inside iframes, allowing apps to request signing, key access, and encryption from the parent portal without exposing secret keys.
|
||||
|
||||
### Identity Protocol
|
||||
|
||||
Identity-aware apps (IndeedHub) receive user identity via `archipelago:identity` postMessage after an identity picker modal. Identity includes DID, pubkey, npub, and a signed challenge for verification.
|
||||
|
||||
### Payment Protocol
|
||||
|
||||
Apps can request Bitcoin payments via `archipelago:payment-request` postMessage. The parent validates, shows a confirmation modal, executes the payment (ecash/LN/on-chain based on amount), and responds with a receipt.
|
||||
|
||||
### iframe Load Fallback
|
||||
|
||||
If an iframe fails to load within 15 seconds or loads empty content, a fallback UI is shown with a "Can't display in frame" message and an "Open in new tab" button.
|
||||
|
||||
---
|
||||
|
||||
## Decision Framework
|
||||
|
||||
When adding a new app to Archipelago:
|
||||
|
||||
```
|
||||
1. Does the app set X-Frame-Options or CSP frame-ancestors?
|
||||
├── No → iframe via /app/{id}/ proxy, done
|
||||
└── Yes →
|
||||
2. Can you strip headers at Nginx?
|
||||
├── Yes, and app works → iframe via /app/{id}/ proxy
|
||||
└── App still broken after stripping →
|
||||
3. Does the app have JavaScript frame-busting?
|
||||
├── Yes → Open in new tab (add to mustOpenInNewTab)
|
||||
└── No →
|
||||
4. Is it a base path issue?
|
||||
├── Yes → Configure app's native base path or use sub_filter
|
||||
└── No →
|
||||
5. Is it a WebSocket issue?
|
||||
├── Yes → Add WebSocket proxy config
|
||||
└── No →
|
||||
6. Is it a cookie/auth issue?
|
||||
├── Yes → Same-origin proxy should fix it
|
||||
└── No → Debug with browser DevTools, check console errors
|
||||
```
|
||||
33
.claude/memory/MEMORY.md
Normal file
33
.claude/memory/MEMORY.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Archipelago Project Memory Index
|
||||
|
||||
## Setup & Architecture
|
||||
- [claude-proxy-setup.md](claude-proxy-setup.md) — Claude proxy OAuth setup details
|
||||
- [deploy-automation.md](deploy-automation.md) — Deploy script automation TODOs (API key, AIUI nginx, swap)
|
||||
|
||||
## Servers & Deploy
|
||||
- [tailscale_servers.md](tailscale_servers.md) — Tailscale server details (archipelago-2, archipelago-3)
|
||||
- [reference_tailscale_nodes.md](reference_tailscale_nodes.md) — All node IPs and SSH commands
|
||||
- [second-server.md](second-server.md) — Second dev server (archipelago-2 via Tailscale)
|
||||
- [third-server.md](third-server.md) — Third dev server (archipelago-3 via Tailscale)
|
||||
|
||||
## Features & Plans
|
||||
- [pending-features.md](pending-features.md) — Feature requests: kiosk mode, sideloading, Nostr login, etc.
|
||||
- [project-plan.md](project-plan.md) — Overall project plan status
|
||||
- [web-only-apps.md](web-only-apps.md) — Web-only apps (L484 category) and iframe compatibility
|
||||
|
||||
## User Feedback
|
||||
- [feedback_app_display_modes.md](feedback_app_display_modes.md) — App browser: 3 display modes with persistent setting
|
||||
- [feedback_fullscreen_modals.md](feedback_fullscreen_modals.md) — Fullscreen modal preferences
|
||||
- [feedback_local_dev.md](feedback_local_dev.md) — Local dev: use `cd neode-ui && ./start-dev.sh`
|
||||
- [feedback_apps_always_direct_port.md](feedback_apps_always_direct_port.md) — Apps MUST open at direct port, NEVER proxy paths
|
||||
- [feedback_indeedhub_nginx_ips.md](feedback_indeedhub_nginx_ips.md) — IndeedHub nginx must use hardcoded container IPs
|
||||
- [feedback_searxng_no_cap_drop.md](feedback_searxng_no_cap_drop.md) — SearXNG: no cap-drop ALL
|
||||
|
||||
## ISO Build
|
||||
- [iso-build-session-2026-03-10.md](iso-build-session-2026-03-10.md) — ISO build session notes
|
||||
- [unbundled-iso.md](unbundled-iso.md) — Unbundled ISO approach notes
|
||||
|
||||
## Completed Work
|
||||
- [project_mesh_198_issue.md](project_mesh_198_issue.md) — Mesh .198: 3 bugs fixed and deployed
|
||||
- [project_indeedhub_arch3_fix.md](project_indeedhub_arch3_fix.md) — IndeedHub Arch 3: corrupted combined tarball fixed
|
||||
- [project_demo_deploy.md](project_demo_deploy.md) — Demo prod deployment via Portainer
|
||||
9
.claude/memory/claude-proxy-setup.md
Normal file
9
.claude/memory/claude-proxy-setup.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: claude-proxy-oauth-primary-only
|
||||
description: Only the primary server (192.168.1.228) uses OAuth proxy for Claude. Second server uses standard API key.
|
||||
type: feedback
|
||||
---
|
||||
|
||||
- Only the primary dev server (192.168.1.228) uses the Claude Max OAuth proxy for AIUI
|
||||
- The second dev server and any other deployments should use a standard Anthropic API key (`ANTHROPIC_API_KEY` env var)
|
||||
- Do not replicate the OAuth proxy setup to other servers
|
||||
18
.claude/memory/deploy-automation.md
Normal file
18
.claude/memory/deploy-automation.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: deploy-automation-todos
|
||||
description: Items to incorporate into the deploy script for automated setup — Claude API key, AIUI nginx config, swap
|
||||
type: project
|
||||
---
|
||||
|
||||
## Deploy Script Automation TODOs
|
||||
|
||||
These items should be added to `scripts/deploy-to-target.sh` so new server deploys are self-contained:
|
||||
|
||||
1. **Claude API key setup**: After deploying the proxy, auto-configure `/opt/archipelago/claude-proxy/.env.local` with the `ANTHROPIC_API_KEY`. On non-primary servers, use the standard API key (not OAuth). The key is: `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`
|
||||
|
||||
2. **AIUI nginx location block**: Ensure nginx config includes a proper `/aiui/` location block so static JS/CSS files are served with correct MIME types. Without this, AIUI fails to load modules.
|
||||
|
||||
3. **Swap space**: Deploy script should check for swap and create 4GB if missing (`fallocate -l 4G /swapfile && mkswap && swapon + fstab entry`).
|
||||
|
||||
4. **Primary server (192.168.1.228)**: 4GB swap configured on 2026-03-11.
|
||||
5. **Second server (archipelago-2)**: 4GB swap configured on 2026-03-11.
|
||||
15
.claude/memory/feedback_app_display_modes.md
Normal file
15
.claude/memory/feedback_app_display_modes.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: App display modes
|
||||
description: App session browser should support 3 display modes - right panel, full overlay, and fullscreen - with a persistent setting
|
||||
type: feedback
|
||||
---
|
||||
|
||||
App session views (the built-in browser for launching apps) should support three display modes, controlled by a setting dropdown in the header bar:
|
||||
|
||||
1. **Display in right panel** — app loads inside the dashboard's right content area (sidebar visible)
|
||||
2. **Display over whole app** — app overlays the entire viewport including sidebar (like old AppLauncherOverlay with `fixed inset-0 z-[2400]`)
|
||||
3. **Open fullscreen** — uses browser Fullscreen API for true fullscreen
|
||||
|
||||
**Why:** The user likes the right-panel approach (screenshot showed it working well) but also wants the option to go full overlay or fullscreen. The setting should persist (localStorage) and apply to all apps globally.
|
||||
|
||||
**How to apply:** Store the preference in localStorage. The header bar should have a dropdown/toggle with icons for the three modes. Default to "right panel" mode.
|
||||
35
.claude/memory/feedback_apps_always_direct_port.md
Normal file
35
.claude/memory/feedback_apps_always_direct_port.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: Apps MUST open at direct port — NEVER proxy paths
|
||||
description: CRITICAL — All apps in iframes must open at their direct port (http(s)://{host}:{port}), NEVER through /app/{id}/ proxy paths. This is the #1 cause of broken app loading across all nodes.
|
||||
type: feedback
|
||||
---
|
||||
|
||||
## CRITICAL RULE: Apps load at DIRECT PORT, never proxy paths
|
||||
|
||||
All Archipelago apps that open in iframes MUST use the direct port URL:
|
||||
```
|
||||
{protocol}://{hostname}:{port}
|
||||
```
|
||||
|
||||
**NEVER** use path-based proxy URLs like `/app/indeedhub/` or `/app/mempool/` for iframe loading. Path proxies break apps because:
|
||||
1. The main nginx SPA catch-all serves the Archipelago dashboard instead of the app
|
||||
2. sub_filter URL rewrites break client-side routing in Vue/React apps
|
||||
3. Different nodes have different nginx configs — path proxies are unreliable
|
||||
|
||||
**Why:** This was broken THREE TIMES in one session (2026-03-17). Every time the iframe URL used a proxy path instead of the direct port, the app showed the Archipelago dashboard or a blank page. .228 and .198 work correctly because they use HTTP which naturally hits the direct port. Tailscale nodes use HTTPS which was falling through to the proxy path.
|
||||
|
||||
**How to apply:**
|
||||
- In `AppSession.vue`, apps like IndeedHub must ALWAYS construct `{protocol}://{hostname}:{port}` — even on HTTPS
|
||||
- The `HTTPS_PROXY_PATHS` mapping should NOT include apps that have X-Frame-Options removed (like IndeedHub)
|
||||
- When adding new apps: use PORT_APPS for the port mapping, do NOT add to HTTPS_PROXY_PATHS unless absolutely necessary
|
||||
- The deploy script removes X-Frame-Options from IndeedHub's internal nginx, enabling direct port iframe access
|
||||
|
||||
**Also critical for IndeedHub specifically:**
|
||||
- IndeedHub nginx MUST use hardcoded container IPs (not DNS names) — see feedback_indeedhub_nginx_ips.md
|
||||
- nostr-provider.js must be injected via sub_filter in the IndeedHub internal nginx
|
||||
- SearXNG must NOT use --cap-drop ALL — see feedback_searxng_no_cap_drop.md
|
||||
|
||||
**When recreating containers:**
|
||||
- NEVER recreate containers without reapplying ALL patches (X-Frame-Options removal, nostr-provider injection, IP hardcoding)
|
||||
- After any container IP change (restart, recreation), update the hardcoded IPs in IndeedHub's nginx config
|
||||
- Deploy the SAME frontend build to ALL nodes — version mismatch causes different behavior
|
||||
11
.claude/memory/feedback_fullscreen_modals.md
Normal file
11
.claude/memory/feedback_fullscreen_modals.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: Full-screen modals
|
||||
description: App session modals and overlays must cover the full viewport, not just the right panel area of the dashboard
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Modals and app session overlays must be **full screen** — covering the entire viewport including the sidebar/nav. Do NOT constrain them to just the right content panel of the dashboard layout.
|
||||
|
||||
**Why:** The user has corrected this multiple times. Modals that only cover the right panel look wrong and don't provide an immersive app experience.
|
||||
|
||||
**How to apply:** When creating overlays, modals, or app session views, use `position: fixed; inset: 0; z-index: 2400+` to cover the entire screen. The existing AppLauncherOverlay already does this correctly with `class="fixed inset-0 z-[2400]"` — follow that pattern. On mobile it should be truly fullscreen (no padding/margins). On desktop, the glass panel with margins (md:p-10, md:rounded-2xl) is fine.
|
||||
17
.claude/memory/feedback_indeedhub_nginx_ips.md
Normal file
17
.claude/memory/feedback_indeedhub_nginx_ips.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: IndeedHub nginx must use hardcoded container IPs
|
||||
description: IndeedHub's internal nginx config MUST use hardcoded container IPs, not DNS names — podman DNS resolver 127.0.0.11 is unreliable and causes 502 errors
|
||||
type: feedback
|
||||
---
|
||||
|
||||
IndeedHub's internal nginx config MUST use hardcoded container IPs (e.g., `http://10.89.2.11:4000`) for API, MinIO, and relay upstreams — NEVER DNS names like `http://api:4000` with `resolver 127.0.0.11`.
|
||||
|
||||
**Why:** Podman's embedded DNS resolver (127.0.0.11) is unreliable inside containers. Using DNS names with `resolver 127.0.0.11 valid=30s ipv6=off;` causes nginx to return 502 Bad Gateway errors. This broke IndeedHub on all Tailscale nodes — frontend loaded but API calls failed.
|
||||
|
||||
**How to apply:** After creating/recreating IndeedHub containers, the deploy script must:
|
||||
1. Look up container IPs: `podman inspect indeedhub-build_api_1 --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"`
|
||||
2. Patch the nginx config inside the container to replace DNS names with IPs
|
||||
3. Also patch `$host` → `$http_host` in proxy_set_header Host directives
|
||||
4. Reload nginx inside the container
|
||||
|
||||
This is now handled in `deploy-to-target.sh` and `fix-indeedhub-containers.sh`.
|
||||
15
.claude/memory/feedback_local_dev.md
Normal file
15
.claude/memory/feedback_local_dev.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Local Frontend Dev Workflow
|
||||
description: How to start the local frontend dev environment — use start-dev.sh from neode-ui/, NOT npm start from root
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Run local frontend dev from `neode-ui/` directory: `./start-dev.sh` (NOT `npm start` from project root — there's no root package.json).
|
||||
|
||||
**Why:** The project root has no package.json. Running `npm start` there fails with ENOENT. The frontend dev script lives in `neode-ui/start-dev.sh`.
|
||||
|
||||
**How to apply:**
|
||||
- `cd neode-ui && ./start-dev.sh` — clears ports, starts Docker apps, runs `npm run dev:mock` (mock backend on :5959, Vite on :8100)
|
||||
- Stop with `./stop-dev.sh` or Ctrl+C
|
||||
- Login password in dev mode: `password123`
|
||||
- When telling the user how to test locally, always reference `cd neode-ui && ./start-dev.sh`
|
||||
15
.claude/memory/feedback_searxng_no_cap_drop.md
Normal file
15
.claude/memory/feedback_searxng_no_cap_drop.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: SearXNG must NOT use --cap-drop ALL
|
||||
description: SearXNG container needs write access to /etc/searxng/ for settings.yml — cap-drop ALL causes Permission denied and exit 127
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Do NOT use `--cap-drop ALL` or `--security-opt no-new-privileges:true` when creating the SearXNG container. SearXNG needs to create `/etc/searxng/settings.yml` on first run.
|
||||
|
||||
**Why:** SearXNG's entrypoint creates a settings file from a template. With `--cap-drop ALL`, it gets "Permission denied: can't create '/etc/searxng/settings.yml'" and exits with code 127. The .228 reference server runs SearXNG with default capabilities (only drops CAP_AUDIT_WRITE, CAP_MKNOD, CAP_NET_RAW).
|
||||
|
||||
**How to apply:** When creating SearXNG containers, use:
|
||||
```bash
|
||||
sudo podman run -d --name searxng --restart unless-stopped -p 8888:8080 docker.io/searxng/searxng:latest
|
||||
```
|
||||
No `--cap-drop ALL`, no `--security-opt no-new-privileges:true`.
|
||||
26
.claude/memory/pending-features.md
Normal file
26
.claude/memory/pending-features.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: pending-ui-features
|
||||
description: Feature requests — completed and pending items for the next deployment cycle
|
||||
type: project
|
||||
---
|
||||
|
||||
## Completed (2026-03-11)
|
||||
|
||||
1. **IndieHub in iframe** — Restored. Removed forced new-tab check in `mustOpenInNewTab()`.
|
||||
2. **App uninstall fix** — Backend now logs errors and returns structured response instead of silently swallowing.
|
||||
3. **Login music stops after auth** — Added `stopAllAudio()` + router afterEach guard.
|
||||
4. **Container scanner dev_mode gate removed** — Scanner runs always now.
|
||||
5. **BotFights app** — Added as web-only app with SVG icon. Opens in new tab (X-Frame-Options blocks iframe).
|
||||
6. **L484 web apps** — Added 6 web-only apps: NWNN, 484 Kitchen, Call the Operator, Arch Presentation, Syntropy Institute, T-0. L484 category in marketplace.
|
||||
7. **Kiosk mode** — `/kiosk` route added, `setup-kiosk.sh` installs systemd service, systemd units in image-recipe/configs/. No full-screen iframe overlay — uses standard appLauncher.
|
||||
8. **AIUI first-install fix** — nginx `try_files` changed to `=404`, Chat.vue probes AIUI availability before loading iframe.
|
||||
9. **Web-only apps in My Apps** — Injected synthetic PackageDataEntry objects in Apps.vue. Web-only apps sorted first (alphabetically before container apps). No uninstall/start/stop buttons. Launch uses appLauncher with correct URLs.
|
||||
|
||||
## Pending
|
||||
|
||||
1. **Nostr NIP-07 login for containers** — Sign into container apps using onboarding Nostr keys. Not started.
|
||||
2. **App sideloading** — Settings page to load apps via Docker/OCI image URL. Not started.
|
||||
3. **Encrypted Nostr peer handshake (NIP-04/NIP-44)** — Exchange Tor onion addresses via encrypted DMs instead of public relay events. Not started. Currently onion addresses are published in plaintext on relays.
|
||||
4. **Third server deploy** — archipelago-3.tail2b6225.ts.net needs SSH key setup and first deploy.
|
||||
5. **Kiosk auto-start on servers** — setup-kiosk.sh exists but needs to be run on each server that has a display attached. Not confirmed running on .228.
|
||||
6. **Deploy to .198** — Secondary server not yet deployed with latest changes.
|
||||
62
.claude/memory/project_demo_deploy.md
Normal file
62
.claude/memory/project_demo_deploy.md
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: Demo Deploy Status
|
||||
description: Status and details of the demo prod server deployment via Portainer Stacks from Gitea repos
|
||||
type: project
|
||||
---
|
||||
|
||||
## Demo Prod Deployment — In Progress (2026-03-17)
|
||||
|
||||
### Two Separate Portainer Stacks
|
||||
|
||||
**1. IndeedHub** — DEPLOYED SUCCESSFULLY on :7755
|
||||
- Repo: `https://git.tx1138.com/lfg2025/indee-demo`
|
||||
- Compose: `docker-compose.yml` (root)
|
||||
- Env vars loaded from `.env.portainer` — update DOMAIN, FRONTEND_URL, S3_PUBLIC_BUCKET_URL
|
||||
- APP_PORT defaulted to 7755 (changed from 7777 to avoid conflicts)
|
||||
- Healthcheck fix: pg_isready uses `${POSTGRES_USER}` env var (was hardcoded)
|
||||
- Full 7-service stack: app, api, postgres, redis, minio, minio-init, relay, ffmpeg-worker
|
||||
- Nostr auth is built-in (NIP-98) — users sign in with browser extension (Alby, nos2x)
|
||||
|
||||
**2. Archipelago** — DEPLOYING (last attempt pending)
|
||||
- Repo: `https://git.tx1138.com/lfg2025/archy-demo`
|
||||
- Compose: `docker-compose.demo.yml`
|
||||
- Env vars: `ANTHROPIC_API_KEY` for Claude chat
|
||||
- Port: 4848
|
||||
- Pre-built frontend in `web-dist/` (built locally on Mac, no server-side build)
|
||||
- Backend: `neode-ui/Dockerfile.backend` (Node mock backend on :5959)
|
||||
- Web: `neode-ui/Dockerfile.web` (nginx serving pre-built static files)
|
||||
|
||||
### Issues Resolved So Far
|
||||
- IndeedHub postgres healthcheck hardcoded username → fixed to use env var
|
||||
- Port 7777 conflict → changed to 7755
|
||||
- Archy repo too large (8GB) for Portainer clone → created lightweight `archy-demo` repo
|
||||
- Frontend build failing on server → switched to pre-built static files (no npm/vite on server)
|
||||
- `.dockerignore` blocking `neode-ui/dist` → moved to `web-dist/` at repo root
|
||||
- Docker build cache stale → moved dist outside neode-ui to avoid gitignore conflicts
|
||||
|
||||
### Current Blocker
|
||||
- Last deploy attempt: Docker build cache may still be referencing old paths
|
||||
- If still failing: need to prune Docker build cache on server (`docker builder prune`)
|
||||
|
||||
### Frontend Changes Made
|
||||
- `Apps.vue` and `AppDetails.vue`: IndeedHub removed from WEB_ONLY_APP_URLS (linter change)
|
||||
- IndeedHub will be accessed as a real container or via direct URL to :7755
|
||||
|
||||
### Repo Structure (archy-demo)
|
||||
```
|
||||
archy-demo/
|
||||
├── docker-compose.demo.yml
|
||||
├── .dockerignore
|
||||
├── web-dist/ ← pre-built Vue frontend (from local Mac build)
|
||||
├── demo/aiui/ ← pre-built AIUI chat app
|
||||
└── neode-ui/ ← source + mock backend + docker configs
|
||||
├── Dockerfile.web ← nginx + copy web-dist (no build)
|
||||
├── Dockerfile.backend ← Node mock backend
|
||||
├── docker/nginx-demo.conf
|
||||
├── docker/docker-entrypoint.sh
|
||||
├── mock-backend.js
|
||||
└── src/...
|
||||
```
|
||||
|
||||
**Why:** Demo for showcasing Archipelago + IndeedHub together. Needs to be functional with nostr signing.
|
||||
**How to apply:** When resuming, check if Portainer deploy succeeded. If not, may need to SSH to prune Docker cache or debug further.
|
||||
33
.claude/memory/project_indeedhub_arch3_fix.md
Normal file
33
.claude/memory/project_indeedhub_arch3_fix.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: IndeedHub Arch 3 Fix — 2026-03-17
|
||||
description: Fixed IndeedHub on Arch 3 (100.124.105.113) — corrupted image tarball was root cause, all 7 containers now running
|
||||
type: project
|
||||
---
|
||||
|
||||
## Status: FIXED and working (verified 2026-03-17)
|
||||
|
||||
IndeedHub on Arch 3 (`100.124.105.113`) is fully operational — all 7 containers running, frontend on :7777, API healthy, NIP-07 nostr-provider injected.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The `/tmp/indeedhub-all-images.tar` on Arch 3 was corrupted — `podman save` with multiple images collapsed ALL 7 images to the same image ID (the frontend nginx image `7222645f0b38`). So redis, minio, API, ffmpeg-worker, postgres, and relay were all running the frontend nginx binary.
|
||||
|
||||
**Why:** `podman save` with multiple images sharing layers can produce broken tarballs where all images get the same config/ID.
|
||||
|
||||
## What Was Done
|
||||
|
||||
1. Removed all broken containers and images
|
||||
2. Pulled fresh standard images from Docker Hub (postgres:16-alpine, redis:7-alpine, minio:latest, nostr-rs-relay:latest)
|
||||
3. Exported each custom image as **individual tarballs** from .228 (NOT combined):
|
||||
- `indeedhub-frontend.tar` (149MB, ID: `7222645f0b38`)
|
||||
- `indeedhub-api.tar` (403MB, ID: `2ae2665fc6c7`)
|
||||
- `indeedhub-ffmpeg.tar` (525MB, ID: `cb05b5cf8c25`)
|
||||
4. Transferred via Mac (`.228` → Mac → Arch 3 over Tailscale)
|
||||
5. Loaded images individually, created all 7 containers manually (bypassed the deploy script's broken `podman load` step)
|
||||
6. Copied nostr-provider.js + nginx config with sub_filter from .228 container into Arch 3 container via `podman cp`
|
||||
|
||||
## Remaining Issue — Deploy Script
|
||||
|
||||
The deploy script at `/tmp/deploy-indeedhub.sh` on Arch 3 still references the broken `/tmp/indeedhub-all-images.tar`. If it's run again it will re-corrupt the images. The individual tarballs (`/tmp/indeedhub-frontend.tar`, `/tmp/indeedhub-api.tar`, `/tmp/indeedhub-ffmpeg.tar`) are on Arch 3 and should be used instead.
|
||||
|
||||
**How to apply:** Next time deploying IndeedHub to any node, always export images individually, never as a combined tarball. Consider updating the deploy script to load individual tarballs.
|
||||
20
.claude/memory/project_mesh_198_issue.md
Normal file
20
.claude/memory/project_mesh_198_issue.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Mesh .198 fix — COMPLETED
|
||||
description: Fixed mesh radio on .198 — duplicate init, no reconnect on write fail, wrong device path. All deployed.
|
||||
type: project
|
||||
---
|
||||
|
||||
## Status: COMPLETED (2026-03-17)
|
||||
|
||||
Three bugs were found and fixed:
|
||||
|
||||
1. **Duplicate mesh init in `server.rs`** — removed duplicate block
|
||||
2. **Serial write failures don't trigger reconnection** — added `consecutive_write_failures` counter, bail after 3
|
||||
3. **Device path on .198** — set `/var/lib/archipelago/mesh-config.json` to `/dev/ttyUSB1`
|
||||
|
||||
All changes deployed to both .228 and .198.
|
||||
|
||||
### Files Changed
|
||||
- `core/archipelago/src/server.rs` — removed duplicate mesh/transport init block
|
||||
- `core/archipelago/src/mesh/listener.rs` — added write failure tracking + reconnection
|
||||
- `neode-ui/src/stores/mesh.ts` — fixed TS union type for `typed_payload`
|
||||
21
.claude/memory/reference_tailscale_nodes.md
Normal file
21
.claude/memory/reference_tailscale_nodes.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Tailscale node addresses
|
||||
description: Complete list of all Tailscale node IPs and hostnames for SSH access
|
||||
type: reference
|
||||
---
|
||||
|
||||
## Tailscale Nodes
|
||||
|
||||
| Name | Tailscale IP | Hostname | SSH |
|
||||
|------|-------------|----------|-----|
|
||||
| Arch 1 | 100.82.97.63 | — | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.82.97.63` |
|
||||
| Arch 2 | 100.122.84.60 | archipelago-2.tail2b6225.ts.net | `ssh -i ~/.ssh/archipelago-deploy archipelago@archipelago-2.tail2b6225.ts.net` |
|
||||
| Arch 3 | 100.124.105.113 | archipelago-3.tail2b6225.ts.net | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.124.105.113` |
|
||||
|
||||
Note: `archipelago-3.tail2b6225.ts.net` and `100.124.105.113` are the SAME machine.
|
||||
|
||||
## LAN Nodes
|
||||
| Name | IP | SSH |
|
||||
|------|-----|-----|
|
||||
| Primary (.228) | 192.168.1.228 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` |
|
||||
| Secondary (.198) | 192.168.1.198 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.198` |
|
||||
23
.claude/memory/second-server.md
Normal file
23
.claude/memory/second-server.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: second-dev-server
|
||||
description: Second dev server accessible via Tailscale at archipelago-2.tail2b6225.ts.net, Ryzen 7 7840U, 14GB RAM
|
||||
type: project
|
||||
---
|
||||
|
||||
- Hostname: archipelago-2.tail2b6225.ts.net (Tailscale)
|
||||
- SSH: `ssh -i ~/.ssh/archipelago-deploy archipelago@archipelago-2.tail2b6225.ts.net`
|
||||
- Password: ThunderDome6574839201!
|
||||
- CPU: AMD Ryzen 7 7840U (faster than primary i3-8100T)
|
||||
- RAM: 14GB
|
||||
- Disk: 916GB NVMe
|
||||
- OS: Debian 12 (Bookworm) x86_64
|
||||
- Has: Podman 4.3.1, Node.js v20.20.1, Rust 1.94.0, Nginx 1.22.1
|
||||
- Swap: 4GB configured
|
||||
- Deploy: `ARCHIPELAGO_TARGET="archipelago@archipelago-2.tail2b6225.ts.net" ./scripts/deploy-to-target.sh --live`
|
||||
- Does NOT use OAuth proxy — uses standard ANTHROPIC_API_KEY for Claude/AIUI
|
||||
- First-boot containers created on 2026-03-11 (Bitcoin Knots, LND, Fedimint, PhotoPrism, Ollama, etc.)
|
||||
|
||||
## Pending Fixes for Next Deploy
|
||||
- **AIUI MIME type error**: Nginx needs a `/aiui/` location block serving correct MIME types for JS files. Currently JS files get wrong content-type causing module load failures.
|
||||
- **Self-signed cert warnings**: Expected on fresh deploy, not a bug.
|
||||
- **Container connection errors in AIUI console**: Expected until all containers finish starting and syncing.
|
||||
20
.claude/memory/tailscale_servers.md
Normal file
20
.claude/memory/tailscale_servers.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Tailscale Servers
|
||||
description: Archipelago Tailscale servers (archipelago-2, archipelago-3) — hostnames, SSH access, and deploy notes
|
||||
type: reference
|
||||
---
|
||||
|
||||
## Tailscale Servers
|
||||
|
||||
- **archipelago-2**: `archipelago@archipelago-2.tail2b6225.ts.net`
|
||||
- SSH key auth works (`~/.ssh/archipelago-deploy`)
|
||||
- Has Node.js, npm, Cargo/Rust, Podman — can do full builds
|
||||
- Deploy: `ARCHIPELAGO_TARGET="archipelago@archipelago-2.tail2b6225.ts.net" ./scripts/deploy-to-target.sh --live`
|
||||
|
||||
- **archipelago-3**: `archipelago@archipelago-3.tail2b6225.ts.net` (IP: 100.124.105.113)
|
||||
- SSH key auth works (key added 2026-03-12)
|
||||
- Has Podman only — NO Node.js, NO Rust/Cargo
|
||||
- Cannot build on-server; must copy pre-built binary + frontend tarball
|
||||
- Deploy method: SCP binary from archipelago-2 or local, upload frontend tarball, extract to `/opt/archipelago/web-ui/`
|
||||
|
||||
**How to apply:** For archipelago-2, use the standard deploy script with `ARCHIPELAGO_TARGET`. For archipelago-3, copy pre-built artifacts (binary + frontend tarball) since it lacks build tools.
|
||||
12
.claude/memory/third-server.md
Normal file
12
.claude/memory/third-server.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: third-dev-server
|
||||
description: Third dev server accessible via Tailscale at archipelago-3.tail2b6225.ts.net, password ThisIsWeb54321@
|
||||
type: project
|
||||
---
|
||||
|
||||
- Hostname: archipelago-3.tail2b6225.ts.net (Tailscale)
|
||||
- SSH: `sshpass -p 'ThisIsWeb54321@' ssh -o StrictHostKeyChecking=no archipelago@archipelago-3.tail2b6225.ts.net`
|
||||
- Password: ThisIsWeb54321@
|
||||
- Deploy: `ARCHIPELAGO_TARGET="archipelago@archipelago-3.tail2b6225.ts.net" ./scripts/deploy-to-target.sh --live`
|
||||
- SSH key NOT yet installed — need to copy `~/.ssh/archipelago-deploy.pub` manually
|
||||
- Added 2026-03-11
|
||||
34
.claude/memory/web-only-apps.md
Normal file
34
.claude/memory/web-only-apps.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: web-only-apps
|
||||
description: Web-only apps (no container) — L484 category, BotFights, IndieHub. Iframe compatibility, nginx proxying, My Apps injection.
|
||||
type: project
|
||||
---
|
||||
|
||||
## Web-Only Apps (added 2026-03-11)
|
||||
|
||||
These apps are external websites embedded via iframe — no Docker container. They show as "installed" in both the marketplace and My Apps.
|
||||
|
||||
### L484 Category
|
||||
- **NWNN** (nwnn.l484.com) — News aggregator. No X-Frame-Options. Works in iframe directly.
|
||||
- **484 Kitchen** (484.kitchen) — K484 platform. X-Frame-Options: SAMEORIGIN. Proxied via `/ext/484-kitchen/`.
|
||||
- **Call the Operator** (cta.tx1138.com) — Decentralization portal. No X-Frame-Options. Works in iframe directly.
|
||||
- **Arch Presentation** (present.l484.com) — Archipelago presentation. X-Frame-Options: SAMEORIGIN. Proxied via `/ext/arch-presentation/`.
|
||||
- **Syntropy Institute** (syntropy.institute) — Medicine Reimagined. No X-Frame-Options. Works in iframe directly.
|
||||
- **T-0** (teeminuszero.net) — Decentralization documentary. No X-Frame-Options. Works in iframe directly.
|
||||
|
||||
### Other Web-Only Apps
|
||||
- **BotFights** (botfights.net) — X-Frame-Options: SAMEORIGIN + CSP + COEP/COOP/CORP. Proxied via `/ext/botfights/`. Nginx strips all blocking headers.
|
||||
- **IndeeHub** (archipelago.indeehub.studio) — No X-Frame-Options. Works in iframe directly.
|
||||
|
||||
### Nginx External Proxies
|
||||
Sites with X-Frame-Options get reverse-proxied through nginx at `/ext/{app-id}/`:
|
||||
- `proxy_hide_header X-Frame-Options` strips upstream header
|
||||
- `add_header X-Content-Type-Options "nosniff" always` prevents server-level X-Frame-Options inheritance
|
||||
- BotFights also strips `Cross-Origin-Embedder-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
|
||||
- Proxy locations in both HTTP and HTTPS server blocks of nginx-archipelago.conf
|
||||
|
||||
### Frontend Implementation
|
||||
- **appLauncher.ts**: `EXTERNAL_PROXY` map rewrites external URLs to proxy paths in `toEmbeddableUrl()`
|
||||
- **Apps.vue**: `WEB_ONLY_APPS` constant with synthetic `PackageDataEntry` objects. Sorted first alphabetically. No uninstall/start/stop buttons.
|
||||
- **Marketplace.vue**: `dockerImage: ''` + `webUrl` in `getCuratedAppList()`. L484 category.
|
||||
- **Icons**: `neode-ui/public/assets/img/app-icons/{app-id}.png` (or .svg)
|
||||
138
.claude/plans/luminous-snacking-snowflake.md
Normal file
138
.claude/plans/luminous-snacking-snowflake.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Phase 3 & 4: Encrypted Mesh Messaging + Off-Grid Bitcoin Operations
|
||||
|
||||
## Context
|
||||
|
||||
Phase 1 built the mesh radio layer (Meshcore protocol, serial driver, basic chat). Phase 2 added transport abstraction (Mesh>LAN>Tor routing, CBOR delta sync, Reed-Solomon chunking). Current encryption is static X25519 shared secret per peer — no forward secrecy, no message type discrimination, no store-and-forward.
|
||||
|
||||
Phase 3 adds Signal-style Double Ratchet for forward secrecy, typed messages (ALERT, INVOICE, COORDINATE, PSBT_HASH), and store-and-forward relay. Phase 4 adds off-grid Bitcoin operations: block header relay, transaction relay, Lightning invoice relay, and emergency alert system with dead man's switch.
|
||||
|
||||
## Dependencies to Add
|
||||
|
||||
```toml
|
||||
hkdf = "0.12" # KDF for Double Ratchet chains
|
||||
lightning-invoice = "0.34" # BOLT11 parsing (LDK standard, MIT)
|
||||
```
|
||||
|
||||
Custom Double Ratchet from existing crypto (ed25519-dalek, curve25519-dalek, chacha20poly1305, sha2, hmac) — no DR crate needed.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
mesh/
|
||||
├── x3dh.rs — X3DH key agreement (prekey bundles, 3-way ECDH)
|
||||
├── ratchet.rs — Double Ratchet state machine (forward secrecy)
|
||||
├── session.rs — Per-peer session manager (ratchet state persistence)
|
||||
├── prekey.rs — Prekey store (signed + one-time prekeys, rotation)
|
||||
├── message_types.rs — Typed message envelope (TEXT/ALERT/INVOICE/COORDINATE/PSBT_HASH)
|
||||
├── outbox.rs — Store-and-forward queue (24h TTL, relay hops)
|
||||
├── bitcoin_relay.rs — TX relay, Lightning relay, block header announce
|
||||
├── alerts.rs — Emergency alerts, dead man's switch
|
||||
└── (existing files extended: crypto.rs, listener.rs, types.rs, mod.rs)
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Week 1: X3DH + HKDF Foundation
|
||||
|
||||
**New**: `mesh/x3dh.rs`, `mesh/prekey.rs`
|
||||
**Modify**: `Cargo.toml` (+hkdf), `mesh/crypto.rs`, `mesh/mod.rs`
|
||||
|
||||
- `PrekeyBundle`: identity_key + signed_prekey + one_time_prekeys (CBOR, ~200B)
|
||||
- `PrekeyStore`: disk persistence at `{data_dir}/prekeys/`, rotation, consumption
|
||||
- X3DH: 3-way ECDH → HKDF-SHA256 → root key for Double Ratchet
|
||||
- ARCHY:3 identity broadcast with embedded prekey bundle
|
||||
|
||||
### Week 2: Double Ratchet Protocol
|
||||
|
||||
**New**: `mesh/ratchet.rs` (~500 LOC), `mesh/session.rs` (~300 LOC)
|
||||
|
||||
`RatchetState`: DH ratchet keypair, root key, send/recv chain keys, counters, skipped keys (max 100). HKDF-SHA256 chains + ChaCha20-Poly1305 per-message.
|
||||
|
||||
Wire format: 40B header (DH pub + counters) + 12 nonce + ciphertext + 16 tag = 68B overhead. Single frame: 64B plaintext. Chunked: ~2.4KB.
|
||||
|
||||
`SessionManager`: HashMap<DID, RatchetState>, lazy load from `{data_dir}/ratchet/{did_hash}.json`. Backward compat: falls back to static shared secret for ARCHY:2 peers.
|
||||
|
||||
### Week 3: Typed Messages + Store-and-Forward
|
||||
|
||||
**New**: `mesh/message_types.rs`, `mesh/outbox.rs`
|
||||
**Modify**: `mesh/types.rs`, `mesh/listener.rs`
|
||||
|
||||
CBOR envelope: `[0x02] [{ t: u8, v: bytes, ts: u32, sig?: bytes }]`
|
||||
|
||||
Types: TEXT(0), ALERT(1), INVOICE(2), PSBT_HASH(3), COORDINATE(4), PREKEY_BUNDLE(5), SESSION_INIT(6)
|
||||
|
||||
GPS as `Coordinate { lat_microdeg: i32, lng_microdeg: i32 }` — integer only, no float.
|
||||
|
||||
`MeshOutbox`: VecDeque, 24h TTL, max 3 relay hops, disk persistence. Checked every 10s tick.
|
||||
|
||||
### Week 4: RPC Endpoints + Session Bootstrap
|
||||
|
||||
**Modify**: `api/rpc/mesh.rs`, `api/rpc/mod.rs`, `mesh/listener.rs`
|
||||
|
||||
New RPC: `mesh.send-invoice`, `mesh.send-coordinate`, `mesh.send-alert`, `mesh.outbox`, `mesh.session-status`, `mesh.rotate-prekeys`
|
||||
|
||||
Prekey distribution via ARCHY:3 broadcasts. Session init via X3DH on first message to new peer.
|
||||
|
||||
### Week 5: Off-Grid Bitcoin (Phase 4)
|
||||
|
||||
**New**: `mesh/bitcoin_relay.rs`, `mesh/block_headers.rs`
|
||||
**Modify**: `Cargo.toml` (+lightning-invoice), `api/rpc/mesh.rs`
|
||||
|
||||
Block header relay: Internet node broadcasts `BlockHeaderAnnouncement` (height, hash, Ed25519 sig) on new block. Mesh-only peers display "SPV sync via mesh".
|
||||
|
||||
TX relay: Mesh-only node sends raw tx hex → internet peer calls `sendrawtransaction` → returns txid.
|
||||
|
||||
Lightning relay: Create invoice → send bolt11 → peer pays → proof-of-payment returned.
|
||||
|
||||
### Week 6: Emergency Alerts + Dead Man's Switch
|
||||
|
||||
**New**: `mesh/alerts.rs`
|
||||
|
||||
`DeadManSwitch`: Background task, configurable interval (default 6h), broadcasts signed ALERT with GPS to emergency contacts when triggered. Auto-check-in on any authenticated RPC.
|
||||
|
||||
RPC: `mesh.alert-configure`, `mesh.alert-checkin`, `mesh.alert-test`, `mesh.alert-status`
|
||||
|
||||
### Week 7: Frontend
|
||||
|
||||
**Modify**: `stores/mesh.ts`, `views/Mesh.vue`, `mock-backend.js`
|
||||
|
||||
Message rendering by type: invoice (orange card + Pay button), alert (red card), coordinate (blue card + OSM link), psbt_hash (gray card + Review).
|
||||
|
||||
Session indicator: shield icon (green=ratchet, yellow=static, gray=none).
|
||||
|
||||
Block height in off-grid banner. Alert config panel. Dead man switch toggle.
|
||||
|
||||
### Week 8: Integration Test + Deploy
|
||||
|
||||
E2E on .228 (internet) + .198 (mesh-only): X3DH handshake, 50-message ratchet, invoice relay, TX relay, block headers, dead man switch. Deploy to both servers.
|
||||
|
||||
## New Files (8)
|
||||
|
||||
1. `core/archipelago/src/mesh/x3dh.rs`
|
||||
2. `core/archipelago/src/mesh/prekey.rs`
|
||||
3. `core/archipelago/src/mesh/ratchet.rs`
|
||||
4. `core/archipelago/src/mesh/session.rs`
|
||||
5. `core/archipelago/src/mesh/message_types.rs`
|
||||
6. `core/archipelago/src/mesh/outbox.rs`
|
||||
7. `core/archipelago/src/mesh/bitcoin_relay.rs`
|
||||
8. `core/archipelago/src/mesh/alerts.rs`
|
||||
|
||||
## Modified Files (8)
|
||||
|
||||
1. `core/archipelago/Cargo.toml` — +hkdf, +lightning-invoice
|
||||
2. `core/archipelago/src/mesh/crypto.rs` — +hkdf_sha256, +ephemeral keygen
|
||||
3. `core/archipelago/src/mesh/types.rs` — +message_type, +typed payloads
|
||||
4. `core/archipelago/src/mesh/listener.rs` — typed dispatch, session bootstrap, relay
|
||||
5. `core/archipelago/src/mesh/mod.rs` — new submodules, new MeshService methods
|
||||
6. `core/archipelago/src/api/rpc/mesh.rs` — ~12 new RPC endpoints
|
||||
7. `core/archipelago/src/api/rpc/mod.rs` — register new routes
|
||||
8. `neode-ui/src/views/Mesh.vue` — typed rendering, alert UI, session badges
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cargo test --all-features -- mesh::ratchet mesh::x3dh mesh::session
|
||||
cargo clippy --all-targets --all-features
|
||||
cd neode-ui && npm run type-check
|
||||
./scripts/deploy-to-target.sh --both
|
||||
```
|
||||
108
.claude/plans/polished-napping-squid.md
Normal file
108
.claude/plans/polished-napping-squid.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Meshcore Mesh Networking — Phase 1 Implementation Plan
|
||||
|
||||
## Context
|
||||
|
||||
Adding mesh networking to Archipelago using Heltec V3 devices running Meshcore firmware (Companion USB). Two nodes (.228 and .198) will exchange encrypted identity and text messages over LoRa radio with no internet required. The existing `mesh.rs` wraps the Meshtastic CLI — this replaces it with a native Meshcore serial protocol driver.
|
||||
|
||||
## Architecture
|
||||
|
||||
Convert `mesh.rs` into `mesh/` module directory:
|
||||
|
||||
```
|
||||
core/archipelago/src/mesh/
|
||||
├── mod.rs — Public API, MeshService, config (migrated from mesh.rs)
|
||||
├── types.rs — MeshPeer, MeshMessage, MeshStatus, DeviceType
|
||||
├── protocol.rs — Meshcore binary frame protocol (encode/decode/commands)
|
||||
├── serial.rs — MeshcoreDevice: async serial driver (serial2-tokio)
|
||||
├── crypto.rs — X25519 ECDH + ChaCha20-Poly1305 per-message encryption
|
||||
└── listener.rs — Background tokio task: serial reader + message dispatcher
|
||||
```
|
||||
|
||||
Frontend:
|
||||
```
|
||||
neode-ui/src/stores/mesh.ts — Pinia store
|
||||
neode-ui/src/views/Mesh.vue — Mesh status, peers, messaging UI
|
||||
```
|
||||
|
||||
## Dependency
|
||||
|
||||
Add to `core/archipelago/Cargo.toml`:
|
||||
```toml
|
||||
serial2-tokio = "0.1"
|
||||
```
|
||||
|
||||
All crypto deps already present (chacha20poly1305, ed25519-dalek, curve25519-dalek).
|
||||
|
||||
## Meshcore Protocol Summary
|
||||
|
||||
- **Frame format**: `>` + 2-byte LE length + data (outbound), `<` + 2-byte LE length + data (inbound)
|
||||
- **Baud**: 115200, 8N1
|
||||
- **Max message**: 160 bytes
|
||||
- **Init sequence**: CMD_DEVICE_QUERY (0x16) -> CMD_APP_START (0x01) -> CMD_SET_DEVICE_TIME (0x06)
|
||||
- **Key commands**: SEND_TXT_MSG (0x02), SEND_CHANNEL_TXT_MSG (0x03), GET_CONTACTS (0x04), SYNC_NEXT_MESSAGE (0x0A), SEND_SELF_ADVERT (0x07)
|
||||
- **Push events** (async, >=0x80): NEW_CONTACT (0x8A), ACK (0x82), MESSAGES_WAITING (0x83)
|
||||
|
||||
## Encryption Design
|
||||
|
||||
Reuses existing identity.rs X25519 key agreement:
|
||||
1. Nodes broadcast identity on mesh channel: `ARCHY:1:{did}:{ed25519_pubkey}:{x25519_pubkey}`
|
||||
2. Receiving node derives shared secret: X25519(our_secret, their_x25519_pub)
|
||||
3. All DMs encrypted: ChaCha20-Poly1305 with random 12-byte nonce
|
||||
4. Wire format: [nonce 12B] + [ciphertext] + [tag 16B] — fits in 160B limit for ~130B plaintext
|
||||
|
||||
## RPC Endpoints
|
||||
|
||||
| Method | Action |
|
||||
|--------|--------|
|
||||
| `mesh.status` | Device + mesh status (updated) |
|
||||
| `mesh.peers` | **NEW** — list discovered mesh peers |
|
||||
| `mesh.messages` | **NEW** — get message history (last 100) |
|
||||
| `mesh.send` | **NEW** — send encrypted message to peer |
|
||||
| `mesh.broadcast` | Broadcast identity (updated for Meshcore) |
|
||||
| `mesh.configure` | Update config (updated) |
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Create mesh/ module, migrate existing code** — types.rs + mod.rs from mesh.rs
|
||||
2. **protocol.rs** — Binary frame encode/decode, command builders, response parsers + unit tests
|
||||
3. **crypto.rs** — X25519 ECDH + ChaCha20-Poly1305 encrypt/decrypt + unit tests
|
||||
4. **serial.rs** — MeshcoreDevice with open/init/send/recv + device auto-detection
|
||||
5. **listener.rs** — Background task: serial reader, peer cache, message store, reconnect
|
||||
6. **mod.rs MeshService** — Wraps listener + config, start/stop lifecycle
|
||||
7. **Update RPC handlers** — New endpoints, wire MeshService into RpcHandler
|
||||
8. **Update RPC dispatch** — Add routes in mod.rs ~line 622
|
||||
9. **Frontend store + view** — mesh.ts Pinia store, Mesh.vue with glass-card UI, router + nav
|
||||
10. **Deploy + test** — Deploy to .228 and .198, plug in Heltec V3s, test end-to-end
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
- `core/archipelago/src/mesh.rs` -> delete, replace with `mesh/` directory
|
||||
- `core/archipelago/src/api/rpc/mesh.rs` — update handlers
|
||||
- `core/archipelago/src/api/rpc/mod.rs` — add routes (~line 622)
|
||||
- `core/archipelago/Cargo.toml` — add serial2-tokio
|
||||
- `neode-ui/src/router/index.ts` — add /dashboard/mesh route
|
||||
- `neode-ui/src/views/Dashboard.vue` — add Mesh nav item
|
||||
|
||||
## Reusable Existing Code
|
||||
|
||||
- `identity.rs` lines 140-152: Ed25519 -> X25519 conversion (CompressedEdwardsY -> Montgomery)
|
||||
- `identity.rs` `pubkey_bytes_from_did_key()`: extract raw pubkey from DID string
|
||||
- `node_message.rs` pattern: IncomingMessage store with max 100 circular buffer
|
||||
- `mesh.rs` `MeshConfig` + `load_config`/`save_config`: migrate directly into mod.rs
|
||||
- `mesh.rs` `detect_meshtastic_devices()`: keep as fallback, add Meshcore probe-based detection
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Flash both Heltec V3 with Meshcore **Companion USB** role
|
||||
- Add `archipelago` user to `dialout` group: `usermod -aG dialout archipelago`
|
||||
- Connect Heltec V3 to USB on .228 and .198
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cargo clippy --all-targets` passes with zero warnings
|
||||
2. Unit tests pass: protocol encode/decode, crypto encrypt/decrypt roundtrip
|
||||
3. Device detected on /dev/ttyUSB0 or /dev/ttyACM0
|
||||
4. Init handshake completes (visible in tracing logs)
|
||||
5. Identity broadcast from .228, received on .198
|
||||
6. Encrypted DM sent .228 -> .198, decrypted and visible in UI
|
||||
7. Mesh.vue shows device status, peer list, message history
|
||||
244
.claude/plans/sequential-jingling-moth.md
Normal file
244
.claude/plans/sequential-jingling-moth.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Manage — Claude Code Configuration Dashboard
|
||||
|
||||
## Context
|
||||
|
||||
You have 77 skills, 15 hooks, 17 memory files, 19 plans, and settings across 5 projects + global scope. All stored as flat files (markdown with YAML frontmatter, JSON, bash scripts) under `~/.claude/` and `{project}/.claude/`. Currently the only way to manage these is manually editing files. This project creates a visual web dashboard for browsing, creating, editing, and organizing all of it.
|
||||
|
||||
**Project location**: `/Users/dorian/Projects/Manage`
|
||||
**Stack**: Vue 3 + Vite + TypeScript + Tailwind + Pinia (frontend) + Express + tsx (backend)
|
||||
**Design**: Glassmorphism dark theme (matching Archipelago aesthetic)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (localhost:5173) Express Server (localhost:3141)
|
||||
+-----------------------+ +----------------------------+
|
||||
| Vue 3 SPA | fetch | /api/projects |
|
||||
| +-- Dashboard | ------> | /api/skills (CRUD) |
|
||||
| +-- Skills | | /api/hooks (CRUD) |
|
||||
| +-- Hooks | SSE | /api/memory (CRUD) |
|
||||
| +-- Memory | <------ | /api/plans (CRUD) |
|
||||
| +-- Plans | | /api/settings (R/W) |
|
||||
| +-- Settings | | /api/claude-md (R/W) |
|
||||
| +-- CLAUDE.md | | /api/search |
|
||||
+-----------------------+ | /api/events (SSE) |
|
||||
+-------------+--------------+
|
||||
| chokidar
|
||||
+-------------v--------------+
|
||||
| ~/.claude/ |
|
||||
| ~/Projects/*/.claude/ |
|
||||
+----------------------------+
|
||||
```
|
||||
|
||||
Single command start: `npm start` runs both server + Vite via concurrently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation — Project Setup + Dashboard
|
||||
|
||||
### 1.1 Scaffold project
|
||||
- `npm create vite@latest` with Vue + TypeScript
|
||||
- Install deps: `express`, `cors`, `gray-matter`, `chokidar`, `concurrently`, `tsx`, `@vueuse/core`, `vue-router`, `pinia`, `fuse.js`
|
||||
- Configure `vite.config.ts` with `@` alias and `/api` proxy to `:3141`
|
||||
- Configure Tailwind with glassmorphism tokens from archy
|
||||
|
||||
### 1.2 Design system (`src/style.css`)
|
||||
- Port glassmorphism classes from `neode-ui/src/style.css`: `.glass-card`, `.glass-button`, `.path-option-card`, `.info-card`, `.scope-badge`
|
||||
- New classes: `.skill-card`, `.hook-node`, `.memory-tree-item`, `.plan-progress-bar`, `.editor-panel`
|
||||
- Background: `#0a0a0a`, accent: `#fb923c`
|
||||
|
||||
### 1.3 Backend: Project discovery
|
||||
- **`server/index.ts`** — Express on :3141 with CORS + JSON body parser
|
||||
- **`server/lib/discovery.ts`** — Scan `~/Projects/` for dirs with `.claude/`, decode `~/.claude/projects/` encoded paths, count skills/hooks/memory/plans per project
|
||||
- **`GET /api/projects`** — Return project list with counts
|
||||
|
||||
### 1.4 Frontend: App shell + Dashboard
|
||||
- **`AppShell.vue`** — Sidebar (project switcher + nav links) + router-view content area
|
||||
- **`Sidebar.vue`** — "Global" at top, then project list; active project highlighted; click to switch scope
|
||||
- **`Dashboard.vue`** — Stats row (total skills/hooks/memory/plans) + project cards grid
|
||||
- **`ProjectCard.vue`** — Glass card showing project name, path, skill/hook/memory counts, click to select
|
||||
- **`stores/projects.ts`** — Pinia store: `projects[]`, `activeProject`, `fetchProjects()`, `setActiveProject()`
|
||||
|
||||
**Verify**: `npm start` opens browser, sidebar shows 5 projects + global, dashboard shows stats.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Skills Manager
|
||||
|
||||
### 2.1 Backend
|
||||
- **`server/lib/skill-parser.ts`** — Parse SKILL.md YAML frontmatter via `gray-matter`, handle both `skills/{name}/SKILL.md` (dir-based) and `skills/{name}.md` (flat) formats
|
||||
- **`server/lib/fs-utils.ts`** — Safe read/write/delete/mkdir helpers with atomic writes
|
||||
- **`server/routes/skills.ts`** — Full CRUD + `POST /api/skills/move` for scope transfers
|
||||
|
||||
### 2.2 Frontend
|
||||
- **`Skills.vue`** — Top bar: scope filter, grid/list toggle, category dropdown, search. Grid of SkillCards. FAB for "New Skill"
|
||||
- **`SkillCard.vue`** — Name, description (truncated), scope badge, category color stripe, allowed-tools pills. Click opens editor.
|
||||
- **`SkillEditor.vue`** — Slide-in panel: frontmatter form (name, description, category, tags, allowed-tools, disable-model-invocation toggle) + Monaco editor for markdown body + live preview
|
||||
- **`InheritanceMap.vue`** — Two-column view: global skills left, project skills right, connecting lines for name-matched overrides
|
||||
- **Drag-and-drop**: Drag SkillCard between global/project columns to move/copy. Uses `vue-draggable-plus`.
|
||||
|
||||
**Verify**: Browse all 77 skills, create/edit/delete, drag between scopes, see inheritance.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Hooks Manager
|
||||
|
||||
### 3.1 Backend
|
||||
- **`server/lib/hook-parser.ts`** — Parse `settings.json` hook entries + read referenced `.sh` files. Detect orphaned scripts.
|
||||
- **`server/routes/hooks.ts`** — CRUD + `PUT /toggle` for enable/disable. Creates .sh + updates settings.json atomically.
|
||||
|
||||
### 3.2 Frontend
|
||||
- **`Hooks.vue`** — Grouped by event type (PreToolUse, PostToolUse, UserPromptSubmit, Stop, SessionEnd)
|
||||
- **`HookPipeline.vue`** — Visual flow per hook: `[Event Badge] -> [Matcher Pill] -> [Script Name] -> [Action]` with CSS-drawn connecting arrows
|
||||
- **`HookCard.vue`** — Event type badge (color-coded), matcher, script filename, enabled/disabled toggle switch
|
||||
- **`HookEditor.vue`** — Monaco editor for `.sh` script + form for event type and matcher pattern
|
||||
- Orphaned scripts in "Unlinked Scripts" section with "Link" button
|
||||
|
||||
**Verify**: See all 15 hooks in pipeline view, toggle enable/disable, edit scripts, create new hook.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Memory Browser
|
||||
|
||||
### 4.1 Backend
|
||||
- **`server/lib/memory-parser.ts`** — Parse from both locations: `{project}/.claude/memory/` (git-tracked) and `~/.claude/projects/{encoded}/memory/` (private). Parse YAML frontmatter.
|
||||
- **`server/routes/memory.ts`** — CRUD + auto-sync MEMORY.md index on create/delete
|
||||
|
||||
### 4.2 Frontend
|
||||
- **`Memory.vue`** — Split layout: tree panel (left 300px) + content panel (right)
|
||||
- **`MemoryTree.vue`** — Collapsible tree: Project -> Scope -> Type -> Files. Type badges: user (blue), feedback (orange), project (green), reference (purple)
|
||||
- **`MemoryEditor.vue`** — Frontmatter form (name, description, type dropdown) + Monaco editor + markdown preview toggle
|
||||
- Search input at top filters across titles and content
|
||||
|
||||
**Verify**: Browse all 17 memory files in tree, types color-coded, edit with preview, create new, MEMORY.md auto-updates.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Plans Tracker
|
||||
|
||||
### 5.1 Backend
|
||||
- **`server/lib/plan-parser.ts`** — Extract title from `#`, phases from `##`, tasks from `- [ ]`/`- [x]` with line numbers. Calculate completion percentages.
|
||||
- **`server/routes/plans.ts`** — CRUD + `PUT /task` for toggling single checkbox by line number
|
||||
|
||||
### 5.2 Frontend
|
||||
- **`PlanCard.vue`** — Title, overall progress bar, phase count, "12/47 tasks" text
|
||||
- **`PlanDetail.vue`** — Expanded: title, summary, phases as sections with TaskCheckboxes
|
||||
- **`PhaseBar.vue`** — Segmented bar: green (done) / amber (in-progress) / gray (pending)
|
||||
- **`TaskCheckbox.vue`** — Click toggles checkbox, instant API call to update file
|
||||
- "Edit Raw" switches to Monaco. "New Plan" uses overnight template.
|
||||
|
||||
**Verify**: See all 19 plans with progress bars, toggle checkboxes that persist, create new plan.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Settings + CLAUDE.md Editor
|
||||
|
||||
### 6.1 Settings
|
||||
- **`Settings.vue`** — Scope tabs (Global / Project). Sections:
|
||||
- Permissions: toggle switches for allowed tools
|
||||
- Hooks: visual tree of event -> matcher -> command with add/remove
|
||||
- Plugins: installed plugin cards with enable/disable
|
||||
- Effort Level: dropdown
|
||||
- Raw JSON: toggle to edit settings.json directly in Monaco
|
||||
|
||||
### 6.2 CLAUDE.md
|
||||
- **`ClaudeMd.vue`** — Scope tabs. Monaco editor with markdown syntax. Live preview panel. Unsaved changes indicator. Save button.
|
||||
|
||||
**Verify**: Edit settings, toggle permissions, edit CLAUDE.md with preview, confirm files updated.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish — File Watching, Search, Animations
|
||||
|
||||
### 7.1 Live file watching
|
||||
- **`server/lib/file-watcher.ts`** — chokidar watches all `.claude/` dirs. Debounce 300ms. Push SSE events.
|
||||
- **`useFileWatcher.ts`** composable — EventSource connection, triggers store refresh on changes
|
||||
|
||||
### 7.2 Global search
|
||||
- **`GET /api/search?q=bitcoin`** — Full-text across skills, memory, plans, CLAUDE.md
|
||||
- **`TopBar.vue`** — Cmd+K search input with dropdown results
|
||||
|
||||
### 7.3 Drag-and-drop refinement
|
||||
- `vue-draggable-plus` for skills between scopes and plan task reordering
|
||||
|
||||
### 7.4 Final polish
|
||||
- Loading skeletons, empty states, confirm dialogs on deletes
|
||||
- Keyboard shortcuts: Cmd+K (search), Cmd+S (save), Escape (close panels)
|
||||
- View transitions (fade + slide)
|
||||
|
||||
**Verify**: External file edits trigger UI refresh. Cmd+K searches everything. Drag skills between scopes.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Manage/
|
||||
+-- package.json
|
||||
+-- tsconfig.json
|
||||
+-- vite.config.ts
|
||||
+-- tailwind.config.ts
|
||||
+-- index.html
|
||||
+-- .gitignore
|
||||
+-- server/
|
||||
| +-- index.ts
|
||||
| +-- tsconfig.json
|
||||
| +-- routes/
|
||||
| | +-- projects.ts, skills.ts, hooks.ts, memory.ts
|
||||
| | +-- plans.ts, settings.ts, claude-md.ts, search.ts
|
||||
| +-- lib/
|
||||
| | +-- discovery.ts, skill-parser.ts, hook-parser.ts
|
||||
| | +-- memory-parser.ts, plan-parser.ts, settings-parser.ts
|
||||
| | +-- file-watcher.ts, fs-utils.ts
|
||||
| +-- types/
|
||||
| +-- index.ts
|
||||
+-- src/
|
||||
| +-- main.ts, App.vue, style.css
|
||||
| +-- api/client.ts
|
||||
| +-- router/index.ts
|
||||
| +-- stores/ (projects, skills, hooks, memory, plans, settings, search)
|
||||
| +-- types/ (skill, hook, memory, plan, project, settings)
|
||||
| +-- composables/ (useFileWatcher, useMarkdownPreview, useMonaco)
|
||||
| +-- views/ (Dashboard, Skills, Hooks, Memory, Plans, Settings, ClaudeMd)
|
||||
| +-- components/
|
||||
| +-- layout/ (AppShell, Sidebar, TopBar)
|
||||
| +-- shared/ (GlassCard, GlassButton, ScopeBadge, MonacoEditor, etc.)
|
||||
| +-- dashboard/ (ProjectCard, QuickStats)
|
||||
| +-- skills/ (SkillCard, SkillEditor, SkillList, InheritanceMap)
|
||||
| +-- hooks/ (HookPipeline, HookCard, HookEditor)
|
||||
| +-- memory/ (MemoryTree, MemoryCard, MemoryEditor)
|
||||
| +-- plans/ (PlanCard, PlanDetail, PhaseBar, TaskCheckbox)
|
||||
| +-- settings/ (PermissionToggle, HookConfig, PluginCard)
|
||||
+-- public/
|
||||
+-- favicon.svg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Libraries
|
||||
|
||||
| Library | Purpose |
|
||||
|---------|---------|
|
||||
| `express` + `cors` | Backend HTTP server |
|
||||
| `tsx` | Run TypeScript server without build step |
|
||||
| `concurrently` | Run server + Vite in one command |
|
||||
| `gray-matter` | Parse YAML frontmatter from markdown |
|
||||
| `chokidar` | Watch filesystem for live updates |
|
||||
| `monaco-editor` + `@monaco-editor/loader` | Code editor (md, bash, json, yaml) |
|
||||
| `marked` + `highlight.js` | Markdown rendering with syntax highlighting |
|
||||
| `vue-draggable-plus` | Drag-and-drop for skills and plan tasks |
|
||||
| `fuse.js` | Client-side fuzzy search |
|
||||
| `@vueuse/core` | Vue utilities (useEventSource, useDebounceFn) |
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **Express over Bun**: More predictable on macOS, better middleware ecosystem
|
||||
- **SSE over WebSocket**: File watching is server->client only. SSE auto-reconnects, simpler.
|
||||
- **Monaco over CodeMirror**: VS Code-like editing for all 4 file types
|
||||
- **Atomic settings.json writes**: Read-modify-write with temp file + rename
|
||||
- **MEMORY.md auto-sync**: Create/delete memory files auto-updates the index
|
||||
- **Both skill formats**: Parser handles dir-based and flat-file skills
|
||||
103
.claude/plans/shiny-bouncing-raven.md
Normal file
103
.claude/plans/shiny-bouncing-raven.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Plan: Fix Iframe Apps, Detail Pages, Kiosk, Identity Pairing, NIP-07
|
||||
|
||||
## Context
|
||||
|
||||
Three web-only apps (BotFights, 484 Kitchen, Arch Presentation) show black screens in iframe despite nginx reverse proxies being set up. The kiosk on .228 isn't running. Web-only apps need proper detail pages. The user wants Nostr identity formally paired with DID and NIP-07 browser integration for frictionless login to embedded apps.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Fix iframe black screen (HIGH)
|
||||
|
||||
**Root cause**: Proxied HTML contains root-relative paths (`href="/css/main.css"`). Browser resolves these against the origin root, not `/ext/botfights/`, so all assets 404.
|
||||
|
||||
**Fix**: Add `sub_filter` to nginx proxy blocks to rewrite root-relative paths.
|
||||
|
||||
**File**: `image-recipe/configs/nginx-archipelago.conf` (6 location blocks — 3 HTTP, 3 HTTPS)
|
||||
|
||||
Key additions per block:
|
||||
```nginx
|
||||
proxy_set_header Accept-Encoding ""; # Disable gzip so sub_filter works
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html text/css application/javascript;
|
||||
sub_filter 'href="/' 'href="/ext/{app}/';
|
||||
sub_filter 'src="/' 'src="/ext/{app}/';
|
||||
sub_filter 'action="/' 'action="/ext/{app}/';
|
||||
sub_filter "href='/" "href='/ext/{app}/";
|
||||
sub_filter "src='/" "src='/ext/{app}/";
|
||||
```
|
||||
|
||||
Deploy + nginx reload. Verify in browser DevTools (Network tab — no 404s on assets).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Detail pages for web-only apps (MEDIUM)
|
||||
|
||||
**Problem**: Clicking a web-only app card navigates to `/dashboard/apps/{id}`. AppDetails.vue can't resolve it because web-only apps aren't in `store.packages` or `dummyApps`.
|
||||
|
||||
**Fix**:
|
||||
1. Add 7 web-only apps to `dummyApps` in AppDetails.vue (botfights, nwnn, 484-kitchen, call-the-operator, arch-presentation, syntropy-institute, t-zero) — same pattern as IndeeHub
|
||||
2. Add URL mappings in AppDetails.vue `appUrls` for all 7 (if not already present)
|
||||
3. Hide uninstall/start/stop buttons for web-only apps in AppDetails.vue
|
||||
|
||||
**Files**: `neode-ui/src/views/AppDetails.vue`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Kiosk on .228 (MEDIUM)
|
||||
|
||||
**Problem**: Code exists but was never installed on server. No X11/Chromium packages.
|
||||
|
||||
**Steps** (SSH to .228, no code changes):
|
||||
1. `sudo apt-get install -y xorg chromium unclutter xinit`
|
||||
2. `cd ~/archy && sudo ./scripts/setup-kiosk.sh archipelago`
|
||||
3. `sudo systemctl enable --now archipelago-kiosk.service`
|
||||
4. Verify on monitor
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Pair Nostr identity with DID (LOW)
|
||||
|
||||
**Current state**: Ed25519 (DID) and secp256k1 (Nostr) are separate key pairs, both generated at startup. Not formally linked.
|
||||
|
||||
**Fix**: Include the Nostr secp256k1 pubkey in the DID Document as an additional verification method:
|
||||
- Modify `did_document_from_pubkey_hex()` in `identity.rs` to accept optional Nostr pubkey
|
||||
- Add `EcdsaSecp256k1VerificationKey2019` entry to `verificationMethod` array
|
||||
- Pass Nostr pubkey from server startup context
|
||||
|
||||
**Files**: `core/archipelago/src/identity.rs`, `core/archipelago/src/server.rs`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: NIP-07 Nostr login via iframe injection (EXPLORATORY)
|
||||
|
||||
**Goal**: Web apps in iframe (like IndeeHub) can call `window.nostr.getPublicKey()` and `window.nostr.signEvent()` for frictionless Nostr login.
|
||||
|
||||
**Approach**: Inject a `window.nostr` shim into proxied pages via `sub_filter`, communicating with the parent Archipelago frame via `postMessage`.
|
||||
|
||||
**Steps**:
|
||||
1. Create `neode-ui/public/nostr-provider.js` — implements `window.nostr` interface, uses `postMessage` to parent
|
||||
2. Add `sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';` to nginx ext proxy blocks
|
||||
3. Add `postMessage` listener in AppLauncherOverlay that handles `nostr-getPublicKey` and `nostr-signEvent` by calling backend RPC
|
||||
4. Backend already has `identity.nostr-sign` and `node.nostr-pubkey` RPC endpoints
|
||||
|
||||
**Security**: Validate postMessage origin, prompt user before signing, never expose secret key to frontend.
|
||||
|
||||
**Files**: new `neode-ui/public/nostr-provider.js`, `image-recipe/configs/nginx-archipelago.conf`, AppLauncherOverlay component, `neode-ui/src/stores/appLauncher.ts`
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Task 1 — fix iframe black screen (deploy nginx)
|
||||
2. Task 2 — detail pages (deploy frontend)
|
||||
3. Task 3 — kiosk on .228 (SSH ops)
|
||||
4. Task 4 — DID+Nostr pairing (deploy backend)
|
||||
5. Task 5 — NIP-07 injection (deploy full)
|
||||
|
||||
## Verification
|
||||
|
||||
- Task 1: Open BotFights/484 Kitchen/Arch Presentation in iframe — page renders with styles and interactivity
|
||||
- Task 2: Click web-only app card → detail page shows with title, description, launch button, no container buttons
|
||||
- Task 3: .228 monitor shows kiosk app grid
|
||||
- Task 4: `node.did` RPC returns DID Document with Nostr pubkey in verificationMethod
|
||||
- Task 5: Open IndeeHub in iframe, browser console `window.nostr.getPublicKey()` returns hex pubkey
|
||||
173
.claude/plans/synchronous-greeting-rose.md
Normal file
173
.claude/plans/synchronous-greeting-rose.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Mesh Phase 4 Completion + Phase 5 Implementation
|
||||
|
||||
## Context
|
||||
|
||||
Mesh Phases 1-3 are complete: serial driver, transport layer (Mesh>LAN>Tor), Double Ratchet encryption, typed messages, store-and-forward, chat UI. Phase 4 is 40% done — data structures, builders, and tests exist (`bitcoin_relay.rs`, `alerts.rs`, `message_types.rs`) but nothing is wired into the listener, MeshService, or RPC layer. Phase 5 (steganographic modes, adaptive routing, multi-hardware) is not started.
|
||||
|
||||
## Phase 4: Wire Up Off-Grid Bitcoin Operations (Weeks 8-11)
|
||||
|
||||
### Week 8: Typed Message Dispatch in Listener
|
||||
|
||||
**The critical foundation — everything else depends on this.**
|
||||
|
||||
**`mesh/listener.rs`:**
|
||||
- Add `MeshCommand::SendRaw { dest_pubkey_prefix: [u8; 6], payload: Vec<u8> }` and `BroadcastChannel { channel: u8, payload: Vec<u8> }` variants
|
||||
- In `handle_frame()`: after extracting message bytes, check for `0x02` TypedEnvelope prefix
|
||||
- New `handle_typed_message()` dispatches by type:
|
||||
- `BlockHeader` → validate Ed25519 sig, store in `BlockHeaderCache`, emit event
|
||||
- `TxRelay` → spawn task: Bitcoin RPC `sendrawtransaction`, send `TxRelayResponse` back
|
||||
- `TxRelayResponse` → complete pending in `RelayTracker`, store as MeshMessage
|
||||
- `LightningRelay` → spawn task: LND REST `payinvoice`, send response back
|
||||
- `LightningRelayResponse` → complete pending, store
|
||||
- `Alert` → verify sig, store, emit `MeshEvent::AlertReceived`
|
||||
- Handle `SendRaw` and `BroadcastChannel` in `tokio::select!` command dispatch
|
||||
|
||||
**`mesh/types.rs`:** New `MeshEvent` variants: `BlockHeaderReceived`, `AlertReceived`, `TxRelayCompleted`, `LightningRelayCompleted`
|
||||
|
||||
**Key design:** Spawn separate tokio tasks for Bitcoin/LND HTTP calls (don't block serial read loop). Response sent back via `cmd_tx` channel.
|
||||
|
||||
### Week 9: MeshService Integration + Dead Man's Switch Task
|
||||
|
||||
**`mesh/mod.rs`:**
|
||||
- Add fields: `block_header_cache: Arc<BlockHeaderCache>`, `relay_tracker: Arc<RelayTracker>`, `dead_man_switch: Arc<DeadManSwitch>`, `signing_key: ed25519_dalek::SigningKey`
|
||||
- Init in `new()`, pass cache + tracker into listener via `MeshState`
|
||||
- Accessor methods for RPC layer
|
||||
|
||||
**Dead Man background task** (spawned in `start()`):
|
||||
- Check every 60s: if triggered → build signed alert → broadcast on channel 0 + direct to emergency contacts
|
||||
- Persist `last_check_in_time` as unix timestamp on disk (survives restarts)
|
||||
|
||||
### Week 10: RPC Endpoints
|
||||
|
||||
**`api/rpc/mesh.rs`** — New handlers:
|
||||
|
||||
| Endpoint | Params | Description |
|
||||
|----------|--------|-------------|
|
||||
| `mesh.relay-tx` | `{ tx_hex }` | Queue TX for relay via internet peer |
|
||||
| `mesh.block-headers` | `{ count? }` | Return cached block headers |
|
||||
| `mesh.relay-lightning` | `{ bolt11, amount_sats }` | Queue LN invoice for payment |
|
||||
| `mesh.deadman-status` | — | Query switch state |
|
||||
| `mesh.deadman-configure` | `{ enabled, interval_secs, lat, lng, contacts, custom_message }` | Configure |
|
||||
| `mesh.deadman-checkin` | — | Heartbeat reset |
|
||||
|
||||
**Fix `mesh.send-invoice`:** Replace placeholder bolt11 with real LND `POST /v1/invoices` call.
|
||||
|
||||
**`api/rpc/mod.rs`:** Register all new routes (~line 643).
|
||||
|
||||
### Week 11: Block Header Announcer + Frontend
|
||||
|
||||
**Backend:** Optional background task: poll Bitcoin Core `getblockchaininfo` every 30s → on new block → signed announcement → broadcast channel 0. Config: `announce_block_headers: bool`.
|
||||
|
||||
**Frontend `stores/mesh.ts`:** New methods for all Phase 4 RPC calls.
|
||||
|
||||
**Frontend `views/Mesh.vue`:**
|
||||
- "Off-Grid Bitcoin" panel: block height, headers, TX relay form, LN relay form
|
||||
- "Dead Man's Switch" panel: enable/disable, interval, GPS, contacts, countdown, check-in
|
||||
- Uses `.path-option-card`, `.glass-button`, `.info-card`
|
||||
|
||||
## Phase 5: Mesh Network Intelligence (Weeks 12-15)
|
||||
|
||||
### Week 12: Steganographic Modes
|
||||
|
||||
**New: `mesh/steganography.rs`**
|
||||
|
||||
- `SteganographyMode` enum: `Normal`, `WeatherStation`, `SensorNetwork`
|
||||
- **Weather Station:** Map payload bytes → plausible weather readings (temp, humidity, pressure, wind). Marker `0xAA` replaces `0x02`.
|
||||
- **Sensor Network:** Industrial sensor format (voltage, current, vibration)
|
||||
- `to_wire_steganographic(mode)` / `from_wire_steganographic(data)` on TypedEnvelope
|
||||
- Listener detects `0xAA` → decode stego → normal dispatch
|
||||
- Config: `steganography_mode` in `MeshConfig`
|
||||
- Budget: ~80 bytes real data per 160-byte LoRa frame with stego overhead
|
||||
|
||||
### Week 13: Adaptive Routing & Signal Intelligence
|
||||
|
||||
**New: `mesh/routing.rs`**
|
||||
|
||||
- `LinkQuality` per peer: RSSI/SNR rolling 1h history, packet loss, hop count
|
||||
- `RoutingTable`: link quality per peer + best route per destination DID
|
||||
- Score: `(rssi+120)*0.4 + (snr+20)*0.3 + (1-loss)*100*0.3`
|
||||
- Best relay selection for TX/LN relay (highest quality peer with internet)
|
||||
- Multi-hop forwarding: if dest DID != ours and hops < 3, forward to best next-hop
|
||||
- Extract RSSI from v3 frames (bytes 1-2, currently unused)
|
||||
- RPC: `mesh.routing-table`
|
||||
|
||||
### Week 14: LoRa Radio Parameter Control
|
||||
|
||||
**`mesh/protocol.rs`:** Builders for `SET_RADIO_PARAMS` (0x0B), `SET_TX_POWER` (0x0C), `SET_TUNING_PARAMS` (0x15). Parse `RESP_STATS` (0x18).
|
||||
|
||||
**RPC:** `mesh.set-radio-params`, `mesh.set-tx-power`, `mesh.get-radio-stats`
|
||||
|
||||
**Auto-adaptive SF:** If link quality drops → increase spreading factor (longer range, slower). Config toggle.
|
||||
|
||||
**Frontend:** Radio tuning panel with SF/TX power sliders, stats, auto-adaptive toggle.
|
||||
|
||||
### Week 15: Multi-Hardware + Topology UI
|
||||
|
||||
**New: `mesh/device_trait.rs`**
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait MeshDevice: Send + Sync {
|
||||
async fn open(path: &str) -> Result<Self> where Self: Sized;
|
||||
async fn initialize(&mut self) -> Result<DeviceInfo>;
|
||||
async fn send_text(&mut self, dest: &[u8; 6], msg: &[u8]) -> Result<()>;
|
||||
async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>>;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
- Implement for `MeshcoreDevice`, stub Meshtastic/WiFi/BLE
|
||||
- `listener.rs` uses `Box<dyn MeshDevice>`
|
||||
- **Topology UI:** SVG graph (this node center, peers as satellites), edge thickness = quality, color = green/yellow/red, tooltips with RSSI/SNR/hops
|
||||
- Stego mode selector, block relay status panel
|
||||
|
||||
## Key Challenges
|
||||
|
||||
1. **TX hex > 160 bytes:** Use Reed-Solomon chunking (already in `transport/chunking.rs`)
|
||||
2. **Async in listener:** Spawn tasks for Bitcoin/LND calls, don't block serial loop
|
||||
3. **Dead man false triggers:** Persist check-in time as unix timestamp on disk
|
||||
4. **Stego overhead:** ~80 bytes real data per 160-byte frame
|
||||
|
||||
## Files Modified
|
||||
|
||||
**Phase 4:**
|
||||
- `core/archipelago/src/mesh/listener.rs` — typed dispatch, new MeshCommand variants
|
||||
- `core/archipelago/src/mesh/mod.rs` — new fields, init, background tasks
|
||||
- `core/archipelago/src/mesh/types.rs` — new MeshEvent variants
|
||||
- `core/archipelago/src/api/rpc/mesh.rs` — 6+ new endpoints, fix send-invoice
|
||||
- `core/archipelago/src/api/rpc/mod.rs` — register routes
|
||||
- `neode-ui/src/stores/mesh.ts` — new store methods
|
||||
- `neode-ui/src/views/Mesh.vue` — off-grid + dead man panels
|
||||
|
||||
**Phase 5 new files:**
|
||||
- `core/archipelago/src/mesh/steganography.rs`
|
||||
- `core/archipelago/src/mesh/routing.rs`
|
||||
- `core/archipelago/src/mesh/device_trait.rs`
|
||||
|
||||
## Existing Code to Reuse
|
||||
|
||||
- `bitcoin_relay.rs`: `BlockHeaderCache`, `RelayTracker`, all `build_*` functions
|
||||
- `alerts.rs`: `DeadManSwitch`, `AlertConfig`, `load_config`/`save_config`
|
||||
- `message_types.rs`: All payload types, `TypedEnvelope`, `encode_payload`/`decode_payload`
|
||||
- `api/rpc/lnd.rs:128-141`: `lnd_client()` pattern for LND REST calls
|
||||
- `api/rpc/bitcoin.rs:74-107`: `bitcoin_rpc_call()` for Bitcoin Core RPC
|
||||
- `transport/chunking.rs`: Reed-Solomon FEC for payloads > 160 bytes
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Unit tests on server
|
||||
ssh archipelago@192.168.1.228 'cd ~/archy/core && source ~/.cargo/env && cargo test --all-features -- mesh'
|
||||
|
||||
# Type check frontend
|
||||
cd neode-ui && npm run type-check
|
||||
|
||||
# Deploy to both
|
||||
./scripts/deploy-to-target.sh --both
|
||||
|
||||
# E2E tests:
|
||||
# 1. .228 (internet) relays TX from .198 (mesh-only)
|
||||
# 2. .228 announces block headers, .198 receives them
|
||||
# 3. Dead man's switch triggers after interval, broadcasts alert
|
||||
# 4. Steganographic packet looks like weather data on wire
|
||||
```
|
||||
19
.claude/rules/containers.md
Normal file
19
.claude/rules/containers.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
globs:
|
||||
- "**/container/**"
|
||||
- "**/manifest*"
|
||||
- "**/*podman*"
|
||||
- "**/Containerfile"
|
||||
- "**/Dockerfile"
|
||||
---
|
||||
|
||||
# Container Security Rules (Archipelago)
|
||||
|
||||
- `readonly_root: true` always — containers must not write to their root filesystem
|
||||
- Drop ALL capabilities, add only what's required (`--cap-drop=ALL --cap-add=...`)
|
||||
- Run as non-root user (UID > 1000): `--user 1001:1001`
|
||||
- Set `--security-opt=no-new-privileges:true`
|
||||
- Pin image versions by SHA256 digest, never use `:latest` tag
|
||||
- Mount secrets as read-only files, never pass as environment variables when possible
|
||||
- Set memory and CPU limits on all containers
|
||||
- Use `--network=none` unless network access is required
|
||||
16
.claude/rules/frontend.md
Normal file
16
.claude/rules/frontend.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
globs:
|
||||
- "**/neode-ui/**"
|
||||
- "**/*.vue"
|
||||
---
|
||||
|
||||
# Frontend Rules (Archipelago)
|
||||
|
||||
- Always use `<script setup lang="ts">` in Vue components
|
||||
- Global CSS classes go in `style.css`, never inline Tailwind utilities
|
||||
- Use `.glass-button` for ALL buttons — `.gradient-button` is BANNED
|
||||
- Use Pinia stores for shared state, never provide/inject for cross-component data
|
||||
- Every async view needs: loading state, empty state, and error state
|
||||
- Trim all text inputs before submission
|
||||
- Disable submit buttons during async operations
|
||||
- Use `errorMessage` ref pattern for user-visible errors, not just console.log
|
||||
125
.claude/skills/add-web-app/SKILL.md
Normal file
125
.claude/skills/add-web-app/SKILL.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: add-web-app
|
||||
description: Add an external website as a web-only app to Archipelago (no container needed)
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
|
||||
argument-hint: "[app-id] [url]"
|
||||
---
|
||||
|
||||
Add an external website ($ARGUMENTS) as a web-only app to Archipelago.
|
||||
|
||||
Web-only apps are external websites embedded in the Archipelago UI via iframe. They have no Docker container — they're bookmarks to public websites with full app-like detail pages.
|
||||
|
||||
## Architecture
|
||||
|
||||
External websites that set `X-Frame-Options` or CSP headers blocking iframe embedding are proxied through nginx on **dedicated ports** (one port per site). This approach:
|
||||
- Strips X-Frame-Options so the iframe works
|
||||
- Serves the site at root `/` so SPA routing works correctly
|
||||
- Does NOT use subpath proxying (`/ext/app/`) which breaks SPAs
|
||||
- Optionally injects NIP-07 nostr-provider.js for Nostr login
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Choose a port
|
||||
|
||||
Pick an unused port in the 8900-8999 range. Current allocations:
|
||||
- 8901: botfights.net
|
||||
- 8902: 484.kitchen
|
||||
- 8903: present.l484.com
|
||||
|
||||
### 2. Add nginx proxy server block
|
||||
|
||||
Add a new `server` block to `image-recipe/configs/nginx-archipelago.conf` at the end:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen {PORT};
|
||||
server_name _;
|
||||
location / {
|
||||
proxy_pass https://{DOMAIN};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host {DOMAIN};
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_ssl_server_name on;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_hide_header Cross-Origin-Embedder-Policy;
|
||||
proxy_hide_header Cross-Origin-Opener-Policy;
|
||||
proxy_hide_header Cross-Origin-Resource-Policy;
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
sub_filter_once on;
|
||||
}
|
||||
location = /nostr-provider.js {
|
||||
alias /opt/archipelago/web-ui/nostr-provider.js;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Add to appLauncher.ts EXTERNAL_PROXY_PORT
|
||||
|
||||
In `neode-ui/src/stores/appLauncher.ts`, add the domain-to-port mapping:
|
||||
|
||||
```typescript
|
||||
const EXTERNAL_PROXY_PORT: Record<string, number> = {
|
||||
// ... existing entries
|
||||
'{DOMAIN}': {PORT},
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Add to Apps.vue WEB_ONLY_APP_URLS and WEB_ONLY_APPS
|
||||
|
||||
In `neode-ui/src/views/Apps.vue`:
|
||||
1. Add to `WEB_ONLY_APP_URLS`: `'{app-id}': 'https://{DOMAIN}'`
|
||||
2. Add to `WEB_ONLY_APPS` with a synthetic `PackageDataEntry`:
|
||||
- state: `'running'`
|
||||
- manifest with id, title, version, description
|
||||
- static-files with icon path
|
||||
|
||||
### 5. Add to dummyApps.ts
|
||||
|
||||
In `neode-ui/src/utils/dummyApps.ts`, add a full `PackageDataEntry` with:
|
||||
- Long description (for detail page)
|
||||
- Website URL in manifest
|
||||
- Icon path
|
||||
|
||||
### 6. Add to AppDetails.vue WEB_ONLY_APP_URLS
|
||||
|
||||
In `neode-ui/src/views/AppDetails.vue`, add to the `WEB_ONLY_APP_URLS` map.
|
||||
|
||||
### 7. Add app icon
|
||||
|
||||
Place icon at `neode-ui/public/assets/img/app-icons/{app-id}.{png|webp|svg}`
|
||||
|
||||
### 8. Deploy
|
||||
|
||||
```bash
|
||||
# Build frontend
|
||||
cd neode-ui && npm run build
|
||||
|
||||
# Deploy nginx config
|
||||
scp image-recipe/configs/nginx-archipelago.conf archipelago@192.168.1.228:/tmp/
|
||||
ssh archipelago@192.168.1.228 "sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago && sudo nginx -t && sudo systemctl reload nginx"
|
||||
|
||||
# Deploy frontend
|
||||
rsync -az --delete --exclude aiui --exclude claude-login.html web/dist/neode-ui/ archipelago@192.168.1.228:/opt/archipelago/web-ui/
|
||||
```
|
||||
|
||||
### 9. Verify
|
||||
|
||||
1. Open Archipelago UI
|
||||
2. Web-only app appears in My Apps (sorted alphabetically before container apps)
|
||||
3. Click app card -> detail page with title, description, launch button, no container buttons
|
||||
4. Click Launch -> iframe loads the external website correctly
|
||||
5. All assets load (no 404s in Network tab)
|
||||
6. `window.nostr` available in iframe console (NIP-07)
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | What to add |
|
||||
|------|-------------|
|
||||
| `image-recipe/configs/nginx-archipelago.conf` | New server block with proxy |
|
||||
| `neode-ui/src/stores/appLauncher.ts` | EXTERNAL_PROXY_PORT entry |
|
||||
| `neode-ui/src/views/Apps.vue` | WEB_ONLY_APP_URLS + WEB_ONLY_APPS entries |
|
||||
| `neode-ui/src/views/AppDetails.vue` | WEB_ONLY_APP_URLS entry |
|
||||
| `neode-ui/src/utils/dummyApps.ts` | Full PackageDataEntry for detail page |
|
||||
| `neode-ui/public/assets/img/app-icons/` | App icon file |
|
||||
113
.claude/skills/bitcoin-conventions/SKILL.md
Normal file
113
.claude/skills/bitcoin-conventions/SKILL.md
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: bitcoin-conventions
|
||||
description: Bitcoin development conventions for Archipelago. Covers sats display (integers, never float), address type detection, Tor/onion endpoint preference, Bitcoin RPC error handling, and Lightning patterns. Use when working with Bitcoin amounts, addresses, RPC calls, Lightning channels, or onion services.
|
||||
---
|
||||
|
||||
# Bitcoin Development Conventions
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- **NEVER use floating point for Bitcoin amounts.** Sats are always `u64` (Rust) or `BigInt`/integer (TypeScript).
|
||||
- **NEVER log private keys, seeds, or mnemonics.** Not even at debug/trace level.
|
||||
- **Prefer Tor/onion endpoints** for all Bitcoin network services when available.
|
||||
|
||||
## Amount Display
|
||||
|
||||
### Rust
|
||||
```rust
|
||||
// Amount is always in sats as u64
|
||||
pub fn format_sats(sats: u64) -> String {
|
||||
if sats >= 100_000_000 {
|
||||
let btc = sats / 100_000_000;
|
||||
let remainder = sats % 100_000_000;
|
||||
if remainder == 0 {
|
||||
format!("{} BTC", btc)
|
||||
} else {
|
||||
format!("{}.{:08} BTC", btc, remainder)
|
||||
}
|
||||
} else {
|
||||
format!("{} sats", sats)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
```typescript
|
||||
// Never: amount * 0.00000001
|
||||
// Always: integer arithmetic or BigInt
|
||||
function formatSats(sats: number): string {
|
||||
if (sats >= 100_000_000) {
|
||||
const btc = Math.floor(sats / 100_000_000)
|
||||
const remainder = sats % 100_000_000
|
||||
return remainder === 0 ? `${btc} BTC` : `${btc}.${String(remainder).padStart(8, '0')} BTC`
|
||||
}
|
||||
return `${sats.toLocaleString()} sats`
|
||||
}
|
||||
```
|
||||
|
||||
## Address Types
|
||||
|
||||
Detect and display address type:
|
||||
- `1...` — P2PKH (Legacy)
|
||||
- `3...` — P2SH (SegWit-compatible)
|
||||
- `bc1q...` — P2WPKH (Native SegWit)
|
||||
- `bc1p...` — P2TR (Taproot)
|
||||
|
||||
Always validate addresses before any operation. Use network-appropriate validation (mainnet `bc1`, testnet `tb1`, regtest `bcrt1`).
|
||||
|
||||
## Bitcoin RPC Error Handling
|
||||
|
||||
```rust
|
||||
match rpc_response.error {
|
||||
Some(err) => {
|
||||
// Standard Bitcoin Core RPC error codes
|
||||
match err.code {
|
||||
-1 => /* miscellaneous error */,
|
||||
-5 => /* invalid address or key */,
|
||||
-6 => /* insufficient funds */,
|
||||
-25 => /* transaction verification failed */,
|
||||
-26 => /* transaction rejected by policy */,
|
||||
-27 => /* transaction already in chain */,
|
||||
-28 => /* client still warming up */,
|
||||
_ => /* unknown error */,
|
||||
}
|
||||
}
|
||||
None => { /* success */ }
|
||||
}
|
||||
```
|
||||
|
||||
Always set explicit timeouts on RPC calls (10s default, 30s for heavy operations like `rescanblockchain`).
|
||||
|
||||
## Tor/Onion Preferences
|
||||
|
||||
When configuring Bitcoin services:
|
||||
1. Check for Tor SOCKS proxy (default: `127.0.0.1:9050`)
|
||||
2. If available, route Bitcoin P2P and RPC through Tor
|
||||
3. Prefer `.onion` endpoints for block explorers, electrum servers
|
||||
4. Set `proxy=127.0.0.1:9050` in `bitcoin.conf`
|
||||
5. Set `onlynet=onion` for maximum privacy (if full Tor mode)
|
||||
|
||||
## Lightning (LND/CLN) Patterns
|
||||
|
||||
### BOLT11 Invoice handling
|
||||
- Always validate invoice before displaying to user
|
||||
- Show: amount, description, expiry, destination pubkey
|
||||
- Never auto-pay without user confirmation
|
||||
|
||||
### Channel States
|
||||
Display human-readable channel state:
|
||||
- `PENDING_OPEN` → "Opening..."
|
||||
- `OPEN` → "Active"
|
||||
- `PENDING_CLOSE` / `FORCE_CLOSING` → "Closing..."
|
||||
- `CLOSED` → "Closed"
|
||||
|
||||
### Macaroon handling
|
||||
- Never log macaroon contents
|
||||
- Store with restrictive permissions (0600)
|
||||
- Use read-only macaroon for queries, admin macaroon only for mutations
|
||||
|
||||
## Container Images for Bitcoin Services
|
||||
|
||||
- **Always pin by SHA256 digest**, never by tag alone
|
||||
- Example: `docker.io/lnzap/lnd@sha256:abc123...` not `lnzap/lnd:latest`
|
||||
- Verify image signatures when available (cosign/notary)
|
||||
155
.claude/skills/mesh/SKILL.md
Normal file
155
.claude/skills/mesh/SKILL.md
Normal file
@@ -0,0 +1,155 @@
|
||||
---
|
||||
name: mesh
|
||||
description: Mesh networking development for Archipelago — protocol, crypto, serial driver, transport abstraction, and LoRa chat. Use when working on mesh radio, Meshcore protocol, LoRa messaging, transport layers, peer discovery, or off-grid communication features.
|
||||
---
|
||||
|
||||
# Mesh Networking Skill
|
||||
|
||||
## Architecture
|
||||
|
||||
The mesh subsystem enables offline peer discovery and end-to-end encrypted messaging between Archipelago nodes via Meshcore LoRa radio devices (Heltec V3, T-Beam, RAK WisBlock).
|
||||
|
||||
```
|
||||
USB Meshcore Device (115200 baud)
|
||||
↕ serial2-tokio
|
||||
core/archipelago/src/mesh/
|
||||
├── mod.rs — MeshService: lifecycle, config, public API
|
||||
├── types.rs — MeshPeer, MeshMessage, MeshStatus, MeshEvent
|
||||
├── protocol.rs — Meshcore binary frame protocol (encode/decode)
|
||||
├── serial.rs — MeshcoreDevice: async serial driver
|
||||
├── crypto.rs — X25519 ECDH + ChaCha20-Poly1305 encryption
|
||||
└── listener.rs — Background tokio task: serial reader + dispatcher
|
||||
↕ RPC
|
||||
core/archipelago/src/api/rpc/mesh.rs — 6 endpoints
|
||||
↕ HTTP
|
||||
neode-ui/src/stores/mesh.ts — Pinia store
|
||||
neode-ui/src/views/Mesh.vue — Two-column chat UI
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
### Backend (Rust)
|
||||
- `core/archipelago/src/mesh/mod.rs` — MeshService (start/stop/status/peers/messages/send/configure)
|
||||
- `core/archipelago/src/mesh/types.rs` — All shared types
|
||||
- `core/archipelago/src/mesh/protocol.rs` — Binary frame format, command builders, response parsers (12 unit tests)
|
||||
- `core/archipelago/src/mesh/serial.rs` — USB serial driver, handshake, device detection
|
||||
- `core/archipelago/src/mesh/crypto.rs` — X25519 key agreement + ChaCha20-Poly1305 (7 unit tests)
|
||||
- `core/archipelago/src/mesh/listener.rs` — Background event loop, auto-reconnect, peer cache
|
||||
- `core/archipelago/src/api/rpc/mesh.rs` — RPC handlers (mesh.status/peers/messages/send/broadcast/configure)
|
||||
- `core/archipelago/src/server.rs` — MeshService initialization (non-blocking)
|
||||
- `core/archipelago/src/identity.rs` — Ed25519 keypair, DID, X25519 derivation
|
||||
|
||||
### Frontend (Vue 3 + TypeScript)
|
||||
- `neode-ui/src/stores/mesh.ts` — Pinia store with unread tracking
|
||||
- `neode-ui/src/views/Mesh.vue` — Full chat UI (~1000 lines)
|
||||
- `neode-ui/src/router/index.ts` — Route: `/dashboard/mesh`
|
||||
|
||||
### Mock Backend
|
||||
- `neode-ui/mock-backend.js` — Dev mode mesh RPC responses (mesh.status/peers/messages/send/broadcast/configure)
|
||||
|
||||
## Protocol Reference
|
||||
|
||||
### Meshcore Frame Format
|
||||
- Outbound: `<` (0x3C) + 2-byte LE length + data
|
||||
- Inbound: `>` (0x3E) + 2-byte LE length + data
|
||||
- Max LoRa payload: 160 bytes
|
||||
- Baud: 115200, 8N1
|
||||
|
||||
### Key Commands
|
||||
| Byte | Command | Description |
|
||||
|------|---------|-------------|
|
||||
| 0x01 | APP_START | Init session with version negotiation |
|
||||
| 0x02 | SEND_TXT_MSG | Direct message (6-byte pubkey prefix) |
|
||||
| 0x03 | SEND_CHANNEL_TXT_MSG | Broadcast on channel |
|
||||
| 0x04 | GET_CONTACTS | Fetch contact list |
|
||||
| 0x06 | SET_DEVICE_TIME | Sync device clock |
|
||||
| 0x07 | SEND_SELF_ADVERT | Broadcast identity |
|
||||
| 0x0A | SYNC_NEXT_MESSAGE | Retrieve queued messages |
|
||||
|
||||
### Identity Wire Format
|
||||
`ARCHY:2:{ed25519_hex_64}:{x25519_hex_64}` (137 bytes, fits 160)
|
||||
|
||||
### Encryption
|
||||
- X25519 Diffie-Hellman from Ed25519 keys (RFC 7748 clamping)
|
||||
- ChaCha20-Poly1305 AEAD with random 12-byte nonce
|
||||
- Wire: `[nonce 12B] + [ciphertext + tag 16B]` — max 132B plaintext
|
||||
|
||||
## RPC Endpoints
|
||||
|
||||
| Method | Params | Returns |
|
||||
|--------|--------|---------|
|
||||
| `mesh.status` | — | MeshStatus |
|
||||
| `mesh.peers` | — | `{peers, count}` |
|
||||
| `mesh.messages` | `{limit?}` | `{messages, count}` |
|
||||
| `mesh.send` | `{contact_id, message}` | `{sent, message_id, encrypted}` |
|
||||
| `mesh.broadcast` | — | `{broadcast}` |
|
||||
| `mesh.configure` | `{enabled?, device_path?, channel_name?, broadcast_identity?, advert_name?}` | `{configured}` |
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Building & Testing (on dev server, NOT macOS)
|
||||
```bash
|
||||
# Deploy mesh changes
|
||||
./scripts/deploy-to-target.sh --live
|
||||
|
||||
# Run mesh unit tests on server
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||
'cd ~/archy/core && cargo test --all-features -- mesh'
|
||||
|
||||
# Check device is detected
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||
'ls -la /dev/ttyUSB* /dev/ttyACM* 2>/dev/null'
|
||||
|
||||
# Watch mesh logs
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||
'sudo journalctl -u archipelago -f | grep -i mesh'
|
||||
```
|
||||
|
||||
### Frontend Dev (local, mock backend)
|
||||
```bash
|
||||
cd neode-ui && npm start
|
||||
# Mesh mock data at http://localhost:8100/dashboard/mesh
|
||||
```
|
||||
|
||||
## Roadmap Phases
|
||||
|
||||
### Phase 1: Core Implementation (COMPLETE)
|
||||
- Meshcore binary protocol, serial driver, crypto, listener, RPC, Vue UI
|
||||
|
||||
### Phase 2: Mesh as Federation Transport
|
||||
- NodeTransport trait abstraction (mesh/tor/lan backends)
|
||||
- Transport priority: Mesh (1) > LAN/mDNS (2) > Tor (3)
|
||||
- Chunked message protocol for >160B payloads (Reed-Solomon FEC)
|
||||
- CBOR delta sync instead of full JSON state
|
||||
- Transport indicator per peer in federation UI
|
||||
- "Mesh only" off-grid mode
|
||||
- Dependencies: `ciborium` (CBOR), `reed-solomon-erasure` (FEC), `mdns-sd` (LAN discovery)
|
||||
|
||||
### Phase 3: Encrypted Mesh Messaging
|
||||
- Double Ratchet (Signal protocol) over LoRa
|
||||
- X3DH key agreement using existing Ed25519/X25519
|
||||
- Store-and-forward relay for offline peers (24h TTL)
|
||||
- Message types: TEXT, ALERT, INVOICE (bolt11), PSBT_HASH, COORDINATE
|
||||
- Per-peer chat threads, delivery status, offline indicators
|
||||
|
||||
### Phase 4: Off-Grid Bitcoin Operations
|
||||
- Compact block headers over mesh (SPV verification)
|
||||
- Transaction relay via internet-connected mesh peer
|
||||
- Lightning payment coordination over mesh
|
||||
- Emergency alert system (signed alerts, GPS, dead man's switch)
|
||||
|
||||
### Phase 5: Mesh Network Intelligence
|
||||
- Adaptive routing, signal strength mapping, spreading factor adjustment
|
||||
- Multi-path routing for reliability
|
||||
- Steganographic modes
|
||||
- Additional hardware: T-Beam, RAK WisBlock, WiFi mesh (802.11s), BLE, Blockstream Satellite
|
||||
|
||||
## Conventions
|
||||
|
||||
- All crypto uses existing identity infrastructure (Ed25519 signing key → X25519 derivation)
|
||||
- Mesh init is non-blocking — errors logged but don't crash server
|
||||
- Config persists to `{data_dir}/mesh-config.json`
|
||||
- Message buffer: circular, max 100 messages
|
||||
- Never build Rust on macOS — always deploy to server
|
||||
- USB device paths: `/dev/ttyUSB*` and `/dev/ttyACM*`
|
||||
- `archipelago` user must be in `dialout` group for serial access
|
||||
156
.claude/skills/podman-doctor/SKILL.md
Normal file
156
.claude/skills/podman-doctor/SKILL.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: podman-doctor
|
||||
description: >
|
||||
Comprehensive Podman container diagnostic for Archipelago. Audits all running containers,
|
||||
port mappings, network connectivity, health status, restart policies, and config consistency
|
||||
across all 4 layers (backend Rust, Podman runtime, Nginx proxy, frontend routing).
|
||||
Use when asked to "diagnose containers", "check podman", "why is app not working",
|
||||
"container health check", "port not reachable", "audit containers", "podman status",
|
||||
or when any container/app is misbehaving.
|
||||
allowed-tools: Bash Read Glob Grep
|
||||
---
|
||||
|
||||
# Podman Doctor — Container Infrastructure Diagnostics
|
||||
|
||||
Systematic diagnostic for Archipelago's Podman container stack. Catches port conflicts, network misconfigurations, health failures, missing restart policies, and config drift across all layers.
|
||||
|
||||
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
||||
|
||||
If $ARGUMENTS is provided, focus diagnosis on that specific app/container. Otherwise run full audit.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Gather Runtime State
|
||||
|
||||
Run these on the server:
|
||||
|
||||
```bash
|
||||
# All containers with status, ports, networks
|
||||
sudo podman ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Networks}}"
|
||||
|
||||
# Check for port conflicts on known ports
|
||||
sudo ss -tlnp | grep -E ":(80|443|3000|4080|5678|8080|8081|8082|8083|8085|8096|8123|8173|8174|8175|8240|8332|8333|8334|8888|9735|10009|11434|23000|50001)\b"
|
||||
```
|
||||
|
||||
### Step 2: Check Restart Policies
|
||||
|
||||
Every container MUST have `--restart unless-stopped`. This is the #1 cause of downtime after reboots.
|
||||
|
||||
```bash
|
||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
||||
echo -n "$c: "
|
||||
sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}"
|
||||
done
|
||||
```
|
||||
|
||||
**Red flag**: `no` or empty = container won't survive reboot.
|
||||
|
||||
### Step 3: Verify Port Mapping Consistency
|
||||
|
||||
Cross-reference these 4 layers — mismatches between ANY two cause "app not loading" bugs:
|
||||
|
||||
**Layer 1 — Backend Config (Rust)**: Read `core/archipelago/src/api/rpc/package.rs`, look at `get_app_config()` port mappings.
|
||||
|
||||
**Layer 2 — Podman Runtime**: `sudo podman ps --format "{{.Names}}: {{.Ports}}"`
|
||||
|
||||
**Layer 3 — Nginx Proxy**: Read these for `/app/{id}/` location blocks:
|
||||
- `image-recipe/configs/nginx-archipelago.conf` (HTTP)
|
||||
- `image-recipe/configs/snippets/archipelago-https-app-proxies.conf` (HTTPS)
|
||||
|
||||
**Layer 4 — Frontend Routing**: Read `neode-ui/src/stores/appLauncher.ts` — `PORT_TO_APP_ID` map.
|
||||
|
||||
| Symptom | Root Cause |
|
||||
|---------|-----------|
|
||||
| App iframe shows 502/504 | Nginx proxies to wrong port, or container not running |
|
||||
| App loads wrong content | Port collision — two containers on same host port |
|
||||
| Works on port but not /app/ path | Missing nginx location block |
|
||||
| Frontend can't find app | PORT_TO_APP_ID missing in appLauncher.ts |
|
||||
|
||||
### Step 4: Network Connectivity Audit
|
||||
|
||||
```bash
|
||||
# Networks and their containers
|
||||
sudo podman network ls
|
||||
sudo podman network inspect archy-net 2>/dev/null || echo "WARNING: archy-net missing!"
|
||||
```
|
||||
|
||||
**Must be on archy-net**: bitcoin-knots, lnd, electrs, mempool, btcpay-server, nbxplorer, fedimint, fedimint-gateway, nostr-rs-relay, indeedhub, ollama, open-webui
|
||||
|
||||
**Must NOT be on archy-net**: grafana, nextcloud, filebrowser, vaultwarden, bitcoin-ui, lnd-ui, tailscale (host network)
|
||||
|
||||
### Step 5: Health Check Status
|
||||
|
||||
```bash
|
||||
# Containers with health checks — are they passing?
|
||||
for c in $(sudo podman ps --format "{{.Names}}"); do
|
||||
health=$(sudo podman inspect "$c" --format "{{.State.Health.Status}}" 2>/dev/null)
|
||||
if [ -n "$health" ] && [ "$health" != "<no value>" ]; then
|
||||
echo "$c: $health"
|
||||
fi
|
||||
done
|
||||
|
||||
# Containers WITHOUT health checks (gap in monitoring)
|
||||
for c in $(sudo podman ps --format "{{.Names}}"); do
|
||||
hc=$(sudo podman inspect "$c" --format "{{.Config.Healthcheck}}" 2>/dev/null)
|
||||
if [ "$hc" = "<nil>" ] || [ -z "$hc" ]; then
|
||||
echo "NO HEALTHCHECK: $c"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### Step 6: Resource & Failure Analysis
|
||||
|
||||
```bash
|
||||
# Resource usage
|
||||
sudo podman stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"
|
||||
|
||||
# Recent deaths (last 24h)
|
||||
sudo podman events --filter event=died --since 24h 2>/dev/null | tail -20
|
||||
|
||||
# OOM kills
|
||||
sudo podman ps -a --format "{{.Names}}" | while read c; do
|
||||
oom=$(sudo podman inspect "$c" --format "{{.State.OOMKilled}}" 2>/dev/null)
|
||||
[ "$oom" = "true" ] && echo "OOM KILLED: $c"
|
||||
done
|
||||
|
||||
# Non-zero exits
|
||||
sudo podman ps -a --filter status=exited --format "{{.Names}}\t{{.Status}}"
|
||||
```
|
||||
|
||||
### Step 7: Systemd Integration
|
||||
|
||||
```bash
|
||||
systemctl is-active archipelago nginx
|
||||
systemctl list-units --type=service | grep -i podman
|
||||
systemctl list-timers --all | grep -i -E "podman|container|archipelago"
|
||||
```
|
||||
|
||||
### Step 8: Generate Report
|
||||
|
||||
Produce a structured report:
|
||||
|
||||
```
|
||||
## Container Diagnostic Report
|
||||
|
||||
### Summary
|
||||
- Total containers: X running, Y stopped, Z unhealthy
|
||||
- Port conflicts: [list or "none"]
|
||||
- Missing restart policies: [list or "none"]
|
||||
- Network issues: [list or "none"]
|
||||
- Health check gaps: [list]
|
||||
|
||||
### Critical Issues (fix immediately)
|
||||
1. ...
|
||||
|
||||
### Warnings (fix soon)
|
||||
1. ...
|
||||
|
||||
### Recommended Actions
|
||||
1. ...
|
||||
```
|
||||
|
||||
After diagnosis, suggest running `/podman-fix` for any issues found.
|
||||
|
||||
## Port Reference
|
||||
|
||||
See `references/port-map.md` for the canonical port assignment table across all 4 layers.
|
||||
55
.claude/skills/podman-doctor/references/common-failures.md
Normal file
55
.claude/skills/podman-doctor/references/common-failures.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Common Podman Failure Patterns
|
||||
|
||||
## Container Won't Start
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|-------|-------|-----|
|
||||
| `exec format error` | Binary built on wrong arch | Rebuild on the Linux server |
|
||||
| `address already in use` | Port conflict | `ss -tlnp \| grep :PORT` to find offender |
|
||||
| `permission denied` | Missing capability or read-only root | Check `get_app_capabilities()`, add tmpfs |
|
||||
| `OCI runtime error` | Corrupt container state | `podman rm -f NAME && recreate` |
|
||||
| `image not known` | Image not pulled | `podman pull IMAGE:TAG` |
|
||||
| `no such network` | Network missing | `podman network create archy-net` |
|
||||
|
||||
## Container Starts But App Unreachable
|
||||
|
||||
| Symptom | Check Layer | Fix |
|
||||
|---------|------------|-----|
|
||||
| Direct port works, /app/ doesn't | Nginx config | Add `/app/{id}/` location block |
|
||||
| Neither works | Podman ports | `podman port NAME` — verify mapping exists |
|
||||
| Port mapped but refused | Container logs | App crashing internally — check logs |
|
||||
| Works sometimes | Resources | Check OOM kills, CPU, disk space |
|
||||
| 502 Bad Gateway | Nginx→Container | Wrong port in proxy_pass or container restarted |
|
||||
|
||||
## Container Keeps Dying
|
||||
|
||||
| Pattern | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| Exits immediately (code 1) | Config error | Check `podman logs NAME` |
|
||||
| Dies after minutes | OOM killed | Increase `--memory` limit |
|
||||
| Dies when dep restarts | No restart policy | Add `--restart unless-stopped` |
|
||||
| Crash loop | Repeated crash | Fix root cause, don't just restart |
|
||||
|
||||
## Network Issues
|
||||
|
||||
| Problem | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| Can't resolve container names | Not on archy-net | Recreate with `--network=archy-net` |
|
||||
| Can't reach internet | DNS missing | Add `--dns 1.1.1.1` |
|
||||
| Container-to-container timeout | Different networks | Put both on same network |
|
||||
|
||||
## Capability Reference
|
||||
|
||||
| Capability | Apps That Need It | Failure Mode |
|
||||
|-----------|------------------|-------------|
|
||||
| CHOWN | nextcloud, homeassistant, btcpay, jellyfin, portainer | Can't chown during setup |
|
||||
| SETUID/SETGID | nextcloud, homeassistant, btcpay, jellyfin | Can't switch to service user |
|
||||
| DAC_OVERRIDE | nextcloud, homeassistant, btcpay | Can't access cross-UID files |
|
||||
| FOWNER | bitcoin-knots, lnd, fedimint | Can't modify data dir perms |
|
||||
| NET_BIND_SERVICE | nginx-proxy-manager, vaultwarden | Can't bind ports <1024 |
|
||||
|
||||
## Read-Only Safe Apps
|
||||
|
||||
Only these 8 apps can run with `--read-only`: searxng, grafana, filebrowser, electrs, nostr-rs-relay, ollama, indeedhub
|
||||
|
||||
All others need writable root or will fail silently.
|
||||
71
.claude/skills/podman-doctor/references/port-map.md
Normal file
71
.claude/skills/podman-doctor/references/port-map.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Archipelago Canonical Port Map
|
||||
|
||||
All port assignments across the 4 configuration layers. When adding or debugging an app, every row must be consistent across all columns.
|
||||
|
||||
## Bitcoin Stack
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| bitcoin-knots | 8332, 8333 | 8332, 8333 | archy-net | /app/bitcoin-knots/ | 8332→bitcoin-knots |
|
||||
| bitcoin-ui | 8334 | 80 | bridge | /app/bitcoin-ui/ | 8334→bitcoin-knots |
|
||||
| electrs | 50001 | 50001 | archy-net | /app/electrs/ | 50001→electrs |
|
||||
| lnd | 9735, 10009, 8080 | 9735, 10009, 8080 | archy-net | /app/lnd/ | 10009→lnd |
|
||||
| lnd-ui (RTL) | 8081 | 80 | bridge | /app/lnd-ui/ | 8081→lnd |
|
||||
|
||||
## Lightning & Payment
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| btcpay-server | 23000 | 49392 | archy-net | /app/btcpay/ | 23000→btcpay-server |
|
||||
| nbxplorer | 24444 | 32838 | archy-net | N/A (internal) | N/A |
|
||||
| fedimint | 8173, 8174, 8175 | 8173, 8174, 8175 | archy-net | /app/fedimint/ | 8174→fedimint |
|
||||
| fedimint-gateway | 8175 | 8175 | archy-net | /app/fedimint-gateway/ | 8175→fedimint-gateway |
|
||||
|
||||
## Explorer & Monitoring
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| mempool | 4080 | 8080 | archy-net | /app/mempool/ | 4080→mempool |
|
||||
| grafana | 3000 | 3000 | bridge | /app/grafana/ | 3000→grafana (new tab) |
|
||||
|
||||
## Self-Hosted Apps
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| nextcloud | 8085 | 80 | bridge | /app/nextcloud/ | 8085→nextcloud |
|
||||
| vaultwarden | 8082 | 80 | bridge | /app/vaultwarden/ | 8082→vaultwarden (new tab) |
|
||||
| filebrowser | 8083 | 80 | bridge | /app/filebrowser/ | 8083→filebrowser |
|
||||
| searxng | 8888 | 8080 | bridge | /app/searxng/ | 8888→searxng |
|
||||
| photoprism | 2342 | 2342 | bridge | /app/photoprism/ | 2342→photoprism (new tab) |
|
||||
| jellyfin | 8096 | 8096 | bridge | /app/jellyfin/ | 8096→jellyfin |
|
||||
| homeassistant | 8123 | 8123 | bridge | /app/homeassistant/ | 8123→homeassistant (new tab) |
|
||||
| ollama | 11434 | 11434 | archy-net | /app/ollama/ | 11434→ollama |
|
||||
| open-webui | 3080 | 8080 | archy-net | /app/open-webui/ | 3080→open-webui |
|
||||
|
||||
## Nostr & Social
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| nostr-rs-relay | 7000 | 8080 | archy-net | /app/nostr-rs-relay/ | 7000→nostr-rs-relay |
|
||||
| indeedhub | 3001 | 3000 | archy-net | /app/indeedhub/ | 3001→indeedhub |
|
||||
|
||||
## System
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| tailscale | 8240 | 8240 | host | /app/tailscale/ | N/A |
|
||||
| nginx-proxy-manager | 81, 8443 | 81, 443 | bridge | N/A | 81→nginx-proxy-manager |
|
||||
|
||||
## Multi-Container Stacks
|
||||
|
||||
**Immich**: immich-server (2283), immich-postgres (internal 5432), immich-redis (internal 6379) — all on immich-net
|
||||
**Penpot**: penpot-frontend (9001→80), penpot-backend, penpot-exporter, penpot-postgres, penpot-mailcatch — all on penpot-net
|
||||
**Mempool**: mempool (4080→8080), mempool-db (internal 3306) — on archy-net
|
||||
**BTCPay**: btcpay-server (23000→49392), nbxplorer (24444→32838), btcpay-postgres (internal 5432) — on archy-net
|
||||
|
||||
## Key Notes
|
||||
|
||||
- **archy-net apps** resolve each other by container name (e.g., `bitcoin-knots:8332`)
|
||||
- **bridge apps** are standalone — access services via host IP/port
|
||||
- **host network** (tailscale only) — shares host namespace, no port mapping
|
||||
- **New tab apps**: btcpay (23000), grafana (3000), vaultwarden (8082), photoprism (2342), homeassistant (8123) — X-Frame-Options blocks iframe
|
||||
219
.claude/skills/podman-fix/SKILL.md
Normal file
219
.claude/skills/podman-fix/SKILL.md
Normal file
@@ -0,0 +1,219 @@
|
||||
---
|
||||
name: podman-fix
|
||||
description: >
|
||||
Fix Podman container issues on Archipelago — restart failed containers, repair port bindings,
|
||||
fix network connectivity, add missing restart policies, and resolve config drift.
|
||||
Use when asked to "fix container", "restart app", "fix port mapping", "container not working",
|
||||
"app won't start", "fix podman", "repair container", "container down", or after /podman-doctor
|
||||
identifies issues to fix.
|
||||
allowed-tools: Bash Read Edit Write Glob Grep
|
||||
---
|
||||
|
||||
# Podman Fix — Container Remediation
|
||||
|
||||
Targeted fix workflow for Podman container issues on Archipelago. Given a specific problem (from /podman-doctor or user report), diagnose the root cause and fix it.
|
||||
|
||||
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
||||
|
||||
If $ARGUMENTS is provided, fix that specific app/issue. Otherwise ask what needs fixing.
|
||||
|
||||
## Fix Procedures
|
||||
|
||||
### Fix 1: Container Not Running
|
||||
|
||||
```bash
|
||||
# Check why it stopped
|
||||
sudo podman logs --tail 50 CONTAINER_NAME
|
||||
sudo podman inspect CONTAINER_NAME --format "{{.State.ExitCode}} {{.State.Error}}"
|
||||
|
||||
# If clean exit or crash — just restart
|
||||
sudo podman start CONTAINER_NAME
|
||||
|
||||
# If corrupt state — remove and recreate
|
||||
sudo podman rm -f CONTAINER_NAME
|
||||
# Then recreate using the install flow (trigger from UI or re-run creation command)
|
||||
```
|
||||
|
||||
**If container keeps crashing**: check logs for the actual error. Common causes:
|
||||
- Missing config file → check if volume mount has the config
|
||||
- Wrong permissions → `chown -R` the data directory
|
||||
- Dependency not ready → start dependency first, wait, then start this container
|
||||
|
||||
### Fix 2: Missing Restart Policy
|
||||
|
||||
The most common uptime killer. Fix for ALL containers at once:
|
||||
|
||||
```bash
|
||||
# Fix a single container
|
||||
sudo podman update --restart unless-stopped CONTAINER_NAME
|
||||
|
||||
# Fix ALL containers that have no restart policy
|
||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
||||
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||
if [ "$policy" = "no" ] || [ -z "$policy" ]; then
|
||||
echo "Fixing restart policy for: $c"
|
||||
sudo podman update --restart unless-stopped "$c"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
**Also update the Rust source** so new installs get it right:
|
||||
- Check `core/archipelago/src/api/rpc/package.rs` `get_app_config()` for the app
|
||||
- Ensure `--restart` flag is in the podman run args
|
||||
|
||||
### Fix 3: Port Mapping Issues
|
||||
|
||||
#### Port conflict (address already in use)
|
||||
```bash
|
||||
# Find what's using the port
|
||||
sudo ss -tlnp | grep :PORT_NUMBER
|
||||
|
||||
# If it's another container, either change one's port or stop the conflicting one
|
||||
sudo podman stop CONFLICTING_CONTAINER
|
||||
|
||||
# If it's a host process
|
||||
sudo kill PID # or stop the service
|
||||
```
|
||||
|
||||
#### Port not mapped (container running but port unreachable)
|
||||
```bash
|
||||
# Check current port mappings
|
||||
sudo podman port CONTAINER_NAME
|
||||
|
||||
# Can't add ports to running container — must recreate
|
||||
sudo podman stop CONTAINER_NAME
|
||||
sudo podman rm CONTAINER_NAME
|
||||
# Recreate with correct -p flags (use the Rust install flow or manual podman run)
|
||||
```
|
||||
|
||||
#### Nginx proxy missing or wrong
|
||||
Read and fix the nginx config:
|
||||
- HTTP: `image-recipe/configs/nginx-archipelago.conf`
|
||||
- HTTPS: `image-recipe/configs/snippets/archipelago-https-app-proxies.conf`
|
||||
|
||||
Add a location block:
|
||||
```nginx
|
||||
location /app/APP_ID/ {
|
||||
proxy_pass http://127.0.0.1:HOST_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
# Hide X-Frame-Options so it works in our iframe
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
```
|
||||
|
||||
After editing nginx config, deploy and reload:
|
||||
```bash
|
||||
# On server
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
#### Frontend routing missing
|
||||
Edit `neode-ui/src/stores/appLauncher.ts`:
|
||||
- Add entry to `PORT_TO_APP_ID` map
|
||||
- If app blocks iframes, add port to the new-tab list in `resolveAppIdFromUrl()`
|
||||
|
||||
### Fix 4: Network Issues
|
||||
|
||||
#### Container not on archy-net (can't resolve other containers)
|
||||
```bash
|
||||
# Connect to archy-net without recreating
|
||||
sudo podman network connect archy-net CONTAINER_NAME
|
||||
|
||||
# Verify
|
||||
sudo podman inspect CONTAINER_NAME --format "{{.NetworkSettings.Networks}}"
|
||||
```
|
||||
|
||||
#### archy-net doesn't exist
|
||||
```bash
|
||||
sudo podman network create archy-net
|
||||
# Then reconnect all containers that need it
|
||||
```
|
||||
|
||||
#### DNS not working inside container
|
||||
```bash
|
||||
# Test DNS from inside container
|
||||
sudo podman exec CONTAINER_NAME nslookup bitcoin-knots 2>/dev/null || \
|
||||
sudo podman exec CONTAINER_NAME ping -c1 bitcoin-knots
|
||||
|
||||
# If DNS fails, recreate container with explicit DNS
|
||||
# Add --dns 1.1.1.1 to the podman run command
|
||||
```
|
||||
|
||||
### Fix 5: Health Check Issues
|
||||
|
||||
#### Add missing health check to running container
|
||||
Can't add to running container — must recreate with health check flags:
|
||||
```bash
|
||||
# Example for a web app
|
||||
sudo podman run ... \
|
||||
--health-cmd "curl -f http://localhost:PORT/health || exit 1" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 60s \
|
||||
IMAGE
|
||||
```
|
||||
|
||||
#### Fix unhealthy container
|
||||
```bash
|
||||
# See what the health check is actually running
|
||||
sudo podman inspect CONTAINER_NAME --format "{{.Config.Healthcheck.Test}}"
|
||||
|
||||
# Run the health check manually to see the error
|
||||
sudo podman exec CONTAINER_NAME HEALTH_CHECK_COMMAND
|
||||
|
||||
# Common fixes:
|
||||
# - curl not installed in container → use wget or nc instead
|
||||
# - Wrong port in health check → fix the check command
|
||||
# - App takes too long to start → increase --health-start-period
|
||||
```
|
||||
|
||||
### Fix 6: Permission/Capability Issues
|
||||
|
||||
```bash
|
||||
# Check what capabilities container has
|
||||
sudo podman inspect CONTAINER_NAME --format "{{.HostConfig.CapAdd}}"
|
||||
|
||||
# If missing required caps, must recreate with correct --cap-add flags
|
||||
# Refer to the capability reference in /podman-doctor references
|
||||
|
||||
# Fix data directory permissions
|
||||
sudo chown -R 1000:1000 /var/lib/archipelago/APP_NAME/
|
||||
```
|
||||
|
||||
### Fix 7: Full Config Consistency Fix
|
||||
|
||||
When port map is inconsistent across layers, fix ALL layers:
|
||||
|
||||
1. **Decide the correct port** (usually what's in package.rs)
|
||||
2. **Fix Podman**: recreate container with correct `-p` flags
|
||||
3. **Fix Nginx**: update location block's `proxy_pass` port
|
||||
4. **Fix Frontend**: update `PORT_TO_APP_ID` in appLauncher.ts
|
||||
5. **Deploy**: `./scripts/deploy-to-target.sh --live`
|
||||
6. **Verify**: `curl -I http://192.168.1.228/app/APP_ID/`
|
||||
|
||||
## After Fixing
|
||||
|
||||
Always verify the fix:
|
||||
```bash
|
||||
# Container running?
|
||||
sudo podman ps --filter name=CONTAINER_NAME
|
||||
|
||||
# Port reachable?
|
||||
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:PORT/
|
||||
|
||||
# Via nginx proxy?
|
||||
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1/app/APP_ID/
|
||||
|
||||
# Health check passing?
|
||||
sudo podman inspect CONTAINER_NAME --format "{{.State.Health.Status}}"
|
||||
```
|
||||
|
||||
Run `/podman-doctor` again to confirm all issues are resolved.
|
||||
309
.claude/skills/podman-uptime/SKILL.md
Normal file
309
.claude/skills/podman-uptime/SKILL.md
Normal file
@@ -0,0 +1,309 @@
|
||||
---
|
||||
name: podman-uptime
|
||||
description: >
|
||||
Ensure 100% container uptime on Archipelago. Sets up systemd watchdog timers, verifies
|
||||
restart policies, creates health check monitors, and configures auto-recovery for all
|
||||
containers. Use when asked to "ensure uptime", "containers keep dying", "auto-restart",
|
||||
"watchdog", "container monitoring", "uptime guarantee", "keep containers running",
|
||||
"survive reboot", or to harden container reliability.
|
||||
allowed-tools: Bash Read Edit Write Glob Grep
|
||||
---
|
||||
|
||||
# Podman Uptime — Container Reliability Guardian
|
||||
|
||||
Ensures every Archipelago container survives reboots, recovers from crashes, and stays healthy. Sets up the three layers of uptime defense: restart policies, systemd watchdog, and health-based auto-recovery.
|
||||
|
||||
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
||||
|
||||
## Layer 1: Restart Policies (Survive Reboots)
|
||||
|
||||
Every container MUST have `--restart unless-stopped`. This is non-negotiable.
|
||||
|
||||
### Audit and fix all containers
|
||||
|
||||
```bash
|
||||
# Audit
|
||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
||||
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||
echo "$c: $policy"
|
||||
done
|
||||
|
||||
# Fix any with "no" or empty policy
|
||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
||||
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||
if [ "$policy" = "no" ] || [ -z "$policy" ]; then
|
||||
echo "Fixing: $c"
|
||||
sudo podman update --restart unless-stopped "$c"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### Ensure podman auto-starts containers on boot
|
||||
|
||||
```bash
|
||||
# Enable podman-restart service (restarts containers with restart policy on boot)
|
||||
sudo systemctl enable podman-restart.service 2>/dev/null || true
|
||||
|
||||
# If podman-restart doesn't exist, create it
|
||||
cat <<'EOF' | sudo tee /etc/systemd/system/podman-restart.service
|
||||
[Unit]
|
||||
Description=Podman Start All Containers With Restart Policy
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/podman start --all --filter restart-policy=unless-stopped
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable podman-restart.service
|
||||
```
|
||||
|
||||
## Layer 2: Systemd Watchdog (Detect and Recover)
|
||||
|
||||
Create a systemd timer that checks container health every 2 minutes and restarts unhealthy or stopped containers.
|
||||
|
||||
### Create the watchdog script
|
||||
|
||||
```bash
|
||||
cat <<'SCRIPT' | sudo tee /usr/local/bin/archipelago-container-watchdog.sh
|
||||
#!/bin/bash
|
||||
# Archipelago Container Watchdog
|
||||
# Checks all containers and restarts any that are stopped or unhealthy
|
||||
|
||||
LOG_TAG="container-watchdog"
|
||||
|
||||
# Restart any stopped containers that should be running (have restart policy)
|
||||
for c in $(sudo podman ps -a --filter status=exited --filter restart-policy=unless-stopped --format "{{.Names}}"); do
|
||||
logger -t "$LOG_TAG" "Restarting stopped container: $c"
|
||||
sudo podman start "$c" 2>&1 | logger -t "$LOG_TAG"
|
||||
done
|
||||
|
||||
# Restart unhealthy containers
|
||||
for c in $(sudo podman ps --filter health=unhealthy --format "{{.Names}}"); do
|
||||
logger -t "$LOG_TAG" "Restarting unhealthy container: $c"
|
||||
sudo podman restart "$c" 2>&1 | logger -t "$LOG_TAG"
|
||||
done
|
||||
|
||||
# Check for containers in "created" state (never started)
|
||||
for c in $(sudo podman ps -a --filter status=created --format "{{.Names}}"); do
|
||||
logger -t "$LOG_TAG" "Starting created container: $c"
|
||||
sudo podman start "$c" 2>&1 | logger -t "$LOG_TAG"
|
||||
done
|
||||
SCRIPT
|
||||
|
||||
sudo chmod +x /usr/local/bin/archipelago-container-watchdog.sh
|
||||
```
|
||||
|
||||
### Create the systemd timer
|
||||
|
||||
```bash
|
||||
# Service unit
|
||||
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-watchdog.service
|
||||
[Unit]
|
||||
Description=Archipelago Container Watchdog
|
||||
After=podman-restart.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/archipelago-container-watchdog.sh
|
||||
EOF
|
||||
|
||||
# Timer unit — runs every 2 minutes
|
||||
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-watchdog.timer
|
||||
[Unit]
|
||||
Description=Run Archipelago Container Watchdog every 2 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=120
|
||||
OnUnitActiveSec=120
|
||||
AccuracySec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now archipelago-watchdog.timer
|
||||
```
|
||||
|
||||
### Verify watchdog is running
|
||||
|
||||
```bash
|
||||
sudo systemctl status archipelago-watchdog.timer
|
||||
sudo systemctl list-timers | grep archipelago
|
||||
# Check watchdog logs
|
||||
sudo journalctl -t container-watchdog --since "1 hour ago" --no-pager
|
||||
```
|
||||
|
||||
## Layer 3: Dependency-Aware Startup Order
|
||||
|
||||
Some containers depend on others. The watchdog handles restarts, but initial boot order matters.
|
||||
|
||||
### Create ordered startup script
|
||||
|
||||
```bash
|
||||
cat <<'SCRIPT' | sudo tee /usr/local/bin/archipelago-ordered-start.sh
|
||||
#!/bin/bash
|
||||
# Ordered container startup for Archipelago
|
||||
# Respects dependency chain: bitcoin → electrs/lnd → mempool/btcpay
|
||||
|
||||
LOG_TAG="ordered-start"
|
||||
|
||||
wait_for_container() {
|
||||
local name=$1
|
||||
local max_wait=${2:-60}
|
||||
local waited=0
|
||||
while [ $waited -lt $max_wait ]; do
|
||||
status=$(sudo podman inspect "$name" --format "{{.State.Running}}" 2>/dev/null)
|
||||
if [ "$status" = "true" ]; then
|
||||
logger -t "$LOG_TAG" "$name is running"
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
waited=$((waited + 5))
|
||||
done
|
||||
logger -t "$LOG_TAG" "WARNING: $name not running after ${max_wait}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Tier 0: Infrastructure
|
||||
logger -t "$LOG_TAG" "Starting Tier 0: Infrastructure"
|
||||
sudo podman start tailscale 2>/dev/null
|
||||
|
||||
# Tier 1: Bitcoin (foundation)
|
||||
logger -t "$LOG_TAG" "Starting Tier 1: Bitcoin"
|
||||
sudo podman start bitcoin-knots 2>/dev/null
|
||||
wait_for_container bitcoin-knots 120
|
||||
|
||||
# Tier 2: Bitcoin-dependent services
|
||||
logger -t "$LOG_TAG" "Starting Tier 2: Bitcoin-dependent"
|
||||
sudo podman start electrs 2>/dev/null
|
||||
sudo podman start lnd 2>/dev/null
|
||||
wait_for_container electrs 90
|
||||
wait_for_container lnd 90
|
||||
|
||||
# Tier 3: Services depending on Tier 2
|
||||
logger -t "$LOG_TAG" "Starting Tier 3: Second-order dependencies"
|
||||
sudo podman start mempool-db 2>/dev/null
|
||||
sleep 5
|
||||
sudo podman start mempool 2>/dev/null
|
||||
sudo podman start nbxplorer 2>/dev/null
|
||||
sleep 10
|
||||
sudo podman start btcpay-server 2>/dev/null
|
||||
sudo podman start btcpay-postgres 2>/dev/null
|
||||
|
||||
# Tier 4: Independent apps (start all remaining)
|
||||
logger -t "$LOG_TAG" "Starting Tier 4: Independent apps"
|
||||
sudo podman start --all 2>/dev/null
|
||||
|
||||
# Tier 5: UI containers (need parent apps running first)
|
||||
logger -t "$LOG_TAG" "Starting Tier 5: UI containers"
|
||||
sudo podman start bitcoin-ui 2>/dev/null
|
||||
sudo podman start lnd-ui 2>/dev/null
|
||||
|
||||
logger -t "$LOG_TAG" "Startup sequence complete"
|
||||
SCRIPT
|
||||
|
||||
sudo chmod +x /usr/local/bin/archipelago-ordered-start.sh
|
||||
```
|
||||
|
||||
### Wire into boot sequence
|
||||
|
||||
```bash
|
||||
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-containers.service
|
||||
[Unit]
|
||||
Description=Archipelago Ordered Container Startup
|
||||
After=network-online.target podman.service
|
||||
Wants=network-online.target
|
||||
Before=archipelago.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/archipelago-ordered-start.sh
|
||||
RemainAfterExit=yes
|
||||
TimeoutStartSec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable archipelago-containers.service
|
||||
```
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After setting up all 3 layers, verify:
|
||||
|
||||
```bash
|
||||
echo "=== Layer 1: Restart Policies ==="
|
||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
||||
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||
echo " $c: $policy"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Layer 2: Watchdog Timer ==="
|
||||
sudo systemctl is-active archipelago-watchdog.timer
|
||||
sudo systemctl list-timers | grep archipelago
|
||||
|
||||
echo ""
|
||||
echo "=== Layer 3: Boot Services ==="
|
||||
sudo systemctl is-enabled podman-restart.service 2>/dev/null || echo "podman-restart: not found"
|
||||
sudo systemctl is-enabled archipelago-containers.service 2>/dev/null || echo "ordered-start: not found"
|
||||
sudo systemctl is-enabled archipelago-watchdog.timer 2>/dev/null || echo "watchdog: not found"
|
||||
|
||||
echo ""
|
||||
echo "=== Container Health Summary ==="
|
||||
total=$(sudo podman ps -a --format "{{.Names}}" | wc -l)
|
||||
running=$(sudo podman ps --format "{{.Names}}" | wc -l)
|
||||
stopped=$((total - running))
|
||||
unhealthy=$(sudo podman ps --filter health=unhealthy --format "{{.Names}}" | wc -l)
|
||||
echo " Total: $total | Running: $running | Stopped: $stopped | Unhealthy: $unhealthy"
|
||||
```
|
||||
|
||||
## Reboot Test
|
||||
|
||||
The ultimate uptime test — reboot the server and verify everything comes back:
|
||||
|
||||
```bash
|
||||
# Before reboot: record running containers
|
||||
sudo podman ps --format "{{.Names}}" | sort > /tmp/before-reboot.txt
|
||||
|
||||
# Reboot
|
||||
sudo reboot
|
||||
|
||||
# After reboot (wait ~3 minutes, then SSH back in):
|
||||
sudo podman ps --format "{{.Names}}" | sort > /tmp/after-reboot.txt
|
||||
|
||||
# Compare
|
||||
diff /tmp/before-reboot.txt /tmp/after-reboot.txt
|
||||
# Should show no differences
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
Check uptime status anytime:
|
||||
```bash
|
||||
# Quick status
|
||||
sudo podman ps -a --format "table {{.Names}}\t{{.Status}}" | sort
|
||||
|
||||
# Watchdog activity
|
||||
sudo journalctl -t container-watchdog --since "24 hours ago" --no-pager
|
||||
|
||||
# Container events (starts, stops, deaths)
|
||||
sudo podman events --since 24h --filter event=start --filter event=stop --filter event=died 2>/dev/null | tail -30
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
- Run `/podman-doctor` first to identify issues
|
||||
- Run `/podman-fix` for specific container repairs
|
||||
- Run `/podman-uptime` to set up permanent reliability infrastructure
|
||||
- Add to ISO build: copy watchdog scripts to `image-recipe/configs/` and enable in first-boot
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: polish-backend
|
||||
description: Fix Rust backend quality issues in Archipelago. Eliminates panics/unwraps, adds timeouts, implements connection pooling, fixes clippy warnings. Use when user says "polish backend", "fix unwraps", "backend quality", or "eliminate panics".
|
||||
---
|
||||
|
||||
# Skill: Polish Backend Quality
|
||||
|
||||
Fix Rust backend quality issues: eliminate panics, add timeouts, implement connection pooling, fix clippy warnings. The backend must never crash in production.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: polish-deploy
|
||||
description: Harden Archipelago deployment pipeline with rollback capability, pre-deploy checks, post-deploy health verification, and deployment locking. Use when user says "polish deploy", "harden deployment", "add rollback", or "deploy safety".
|
||||
---
|
||||
|
||||
# Skill: Polish Deployment Pipeline
|
||||
|
||||
Harden deploy-to-target.sh with rollback capability, pre-deploy checks, post-deploy health verification, and deployment locking.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: polish-errors
|
||||
description: Fix silent error handling across Archipelago codebase. Replaces empty catch blocks, adds user-visible error feedback for all async operations. Use when user says "polish errors", "fix error handling", "silent catches", or "error feedback".
|
||||
---
|
||||
|
||||
# Skill: Polish Error Handling
|
||||
|
||||
Fix silent error handling patterns across the entire codebase. Every async operation must have visible, actionable error feedback for the user.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: polish-forms
|
||||
description: Improve form validation across Archipelago UI with real-time feedback, input sanitization, disabled states during submission, and consistent error messaging. Use when user says "polish forms", "form validation", "input validation", or "fix forms".
|
||||
---
|
||||
|
||||
# Skill: Polish Form Validation
|
||||
|
||||
Improve all form inputs to have real-time validation feedback, proper trimming, disabled states during submission, and consistent error messaging.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: polish-loading
|
||||
description: Add skeleton loaders, loading indicators, timeout warnings, and empty states to all Archipelago async views. Use when user says "polish loading", "add skeletons", "loading states", "empty states", or "blank screen fix".
|
||||
---
|
||||
|
||||
# Skill: Polish Loading States
|
||||
|
||||
Add skeleton loaders, loading indicators, timeout warnings, and empty states to all async views. No view should ever show a blank screen.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: polish-security
|
||||
description: Security hardening for Archipelago systemd services, nginx headers, secrets management, and rate limiting. Use when user says "polish security", "harden services", "security headers", "rate limiting", or "secrets management".
|
||||
---
|
||||
|
||||
# Skill: Polish Security
|
||||
|
||||
Security hardening pass for systemd, nginx, secrets management, and rate limiting.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: polish-websocket
|
||||
description: Improve Archipelago WebSocket reliability, reconnection UX, heartbeat monitoring, session timeout detection, and connection status indicators. Use when user says "polish websocket", "fix reconnection", "websocket reliability", or "connection status".
|
||||
---
|
||||
|
||||
# Skill: Polish WebSocket & Real-Time
|
||||
|
||||
Improve WebSocket reliability, reconnection UX, heartbeat, session timeout detection, and connection status indicators.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: polish
|
||||
description: Production polish orchestrator for Archipelago. Coordinates all polish sub-skills by reading plan.md and executing the current week's tasks. Use when user says "polish", "production polish", "overnight polish", or "run the polish plan".
|
||||
---
|
||||
|
||||
# Skill: Production Polish (Overnight Orchestrator)
|
||||
|
||||
Main entry point for the Archipelago production polish plan. Reads `plan.md` at the project root, determines the current week based on today's date, and executes the tasks for that week.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: sweep
|
||||
description: Full automated quality sweep across Archipelago codebase. Checks TypeScript errors, silent catches, console.log, any types, backend unwraps, hardcoded creds, and server health. Use when user says "sweep", "quality check", "run sweep", or "check violations".
|
||||
---
|
||||
|
||||
# Skill: Quality Sweep
|
||||
|
||||
Full automated quality sweep across the entire codebase. Detects regressions, violations, and quality issues. This is the overnight watchdog.
|
||||
|
||||
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
# Ignore everything except what the demo Dockerfiles need
|
||||
*
|
||||
|
||||
# Allow neode-ui (frontend + mock backend + docker configs)
|
||||
!neode-ui/
|
||||
|
||||
# Allow demo assets (AIUI pre-built dist)
|
||||
!demo/
|
||||
|
||||
# Exclude nested node_modules (will npm install in container)
|
||||
neode-ui/node_modules
|
||||
neode-ui/dist
|
||||
45
.gitea/workflows/nightly-security.yml
Normal file
45
.gitea/workflows/nightly-security.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
name: Nightly Security Review
|
||||
on:
|
||||
schedule:
|
||||
- cron: '47 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
security-review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Claude Code
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Run security review on recent changes
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
CHANGED=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || echo "")
|
||||
if [ -z "$CHANGED" ]; then
|
||||
echo "No recent changes to review"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
claude --print "Run a security review focused on these recently changed files:
|
||||
$CHANGED
|
||||
|
||||
Check for:
|
||||
- Constant-time comparison violations in crypto code
|
||||
- Private key material in logs or error messages
|
||||
- Floating-point Bitcoin amounts (must be integer sats)
|
||||
- eval() or unsafe blocks without SAFETY comments
|
||||
- Hardcoded credentials or secrets
|
||||
- Missing input validation at API boundaries
|
||||
|
||||
Output a structured report with severity levels.
|
||||
If any CRITICAL issues found, exit with code 1." > security-report.txt 2>&1
|
||||
|
||||
cat security-report.txt
|
||||
|
||||
if grep -qi "critical" security-report.txt; then
|
||||
echo "::error::Critical security issues found — review security-report.txt"
|
||||
exit 1
|
||||
fi
|
||||
29
.gitea/workflows/weekly-dep-audit.yml
Normal file
29
.gitea/workflows/weekly-dep-audit.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Weekly Dependency Audit
|
||||
on:
|
||||
schedule:
|
||||
- cron: '13 2 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Rust dependency audit
|
||||
run: |
|
||||
cargo install cargo-audit 2>/dev/null || true
|
||||
echo "=== Cargo Audit ==="
|
||||
cargo audit 2>&1 | tee cargo-audit.txt || true
|
||||
|
||||
echo ""
|
||||
echo "=== Version Pinning Check ==="
|
||||
grep -n '"\*"' Cargo.toml || echo "No wildcard versions found"
|
||||
|
||||
- name: Check for critical vulnerabilities
|
||||
run: |
|
||||
if grep -qi "RUSTSEC.*critical\|vulnerability found" cargo-audit.txt 2>/dev/null; then
|
||||
echo "::error::Critical Rust dependency vulnerabilities found"
|
||||
exit 1
|
||||
fi
|
||||
echo "No critical vulnerabilities detected"
|
||||
78
.github/ISSUE_TEMPLATE/app_submission.yml
vendored
Normal file
78
.github/ISSUE_TEMPLATE/app_submission.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: App Submission
|
||||
description: Submit an app for the Archipelago marketplace
|
||||
title: "[App]: "
|
||||
labels: ["app-submission"]
|
||||
body:
|
||||
- type: input
|
||||
id: app_name
|
||||
attributes:
|
||||
label: App Name
|
||||
placeholder: My Bitcoin App
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: docker_image
|
||||
attributes:
|
||||
label: Container Image
|
||||
description: Full image reference with tag (no :latest)
|
||||
placeholder: "ghcr.io/org/app:1.2.3"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What does this app do?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: homepage
|
||||
attributes:
|
||||
label: Homepage / Repository
|
||||
placeholder: "https://github.com/..."
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Category
|
||||
options:
|
||||
- Bitcoin
|
||||
- Lightning
|
||||
- Privacy
|
||||
- Storage
|
||||
- Communication
|
||||
- Development
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: requirements
|
||||
attributes:
|
||||
label: App Requirements Met
|
||||
options:
|
||||
- label: Runs as non-root user (UID > 1000)
|
||||
required: true
|
||||
- label: No `latest` tag — pinned version
|
||||
required: true
|
||||
- label: "Supports x86_64"
|
||||
required: true
|
||||
- label: "Supports ARM64"
|
||||
- label: Tested on Archipelago hardware
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: ports
|
||||
attributes:
|
||||
label: Required Ports
|
||||
description: List ports the app needs exposed
|
||||
placeholder: "8080 (web UI), 9735 (Lightning)"
|
||||
|
||||
- type: textarea
|
||||
id: dependencies
|
||||
attributes:
|
||||
label: Dependencies
|
||||
description: Does this app require other apps (e.g., Bitcoin, LND)?
|
||||
81
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
81
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: Bug Report
|
||||
description: Report a bug in Archipelago
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for reporting a bug. Please fill out the sections below.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear description of the bug.
|
||||
placeholder: What happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Minimal steps to reproduce the issue.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What should have happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Archipelago Version
|
||||
description: Check Settings page or run `archipelago --version`
|
||||
placeholder: "0.1.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: hardware
|
||||
attributes:
|
||||
label: Hardware
|
||||
options:
|
||||
- x86_64 (Intel/AMD)
|
||||
- ARM64 (Raspberry Pi 5)
|
||||
- ARM64 (Other)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Logs
|
||||
description: |
|
||||
Run `journalctl -u archipelago --since "1 hour ago"` and paste relevant output.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots.
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Security Vulnerability
|
||||
url: mailto:security@archipelago-os.org
|
||||
about: Do NOT open public issues for security vulnerabilities. Email us directly.
|
||||
44
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
44
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What problem does this solve?
|
||||
placeholder: I'm always frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: How should this work?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: What other approaches did you consider?
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
options:
|
||||
- Web UI
|
||||
- Backend / API
|
||||
- App Management
|
||||
- Networking
|
||||
- Security
|
||||
- Web5 / Identity
|
||||
- ISO / Installation
|
||||
- Documentation
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
16
.github/pull_request_template.md
vendored
Normal file
16
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
## Summary
|
||||
|
||||
<!-- Brief description of what this PR does -->
|
||||
|
||||
## Changes
|
||||
|
||||
-
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] TypeScript type-check passes (`npm run type-check`)
|
||||
- [ ] Frontend builds (`npm run build`)
|
||||
- [ ] Tests pass (`npm test`)
|
||||
- [ ] Rust clippy clean (if backend changes)
|
||||
- [ ] No new compiler warnings
|
||||
- [ ] Tested on live server
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "indeedhub"]
|
||||
path = indeedhub
|
||||
url = https://git.tx1138.com/lfg2025/indeehub.git
|
||||
133
CHANGELOG.md
133
CHANGELOG.md
@@ -7,6 +7,139 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] - 2026-03-14
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Crash Loop Resolution
|
||||
- Identified and fixed UFW blocking Podman subnet DNS resolution on .228
|
||||
- Fixed archy-nbxplorer, btcpay-server, mempool-web, immich crash loops (3500+ restarts)
|
||||
- All 32 containers stable with zero crash loops after fix
|
||||
|
||||
#### DWN Sync Performance
|
||||
- Made `dwn.sync` endpoint non-blocking (background task with polling)
|
||||
- Added 90-second overall sync timeout to prevent indefinite blocking
|
||||
- Deduplicated peer onion addresses before syncing
|
||||
- Batched message pushes (50/batch) instead of one-at-a-time over Tor
|
||||
- Fixed HTTP handler to process all messages in batch (was only first)
|
||||
|
||||
#### Backup Reliability
|
||||
- Increased backup.create rate limit from 3/600 to 10/600 for testing
|
||||
- Increased backup.restore rate limit from 2/600 to 5/600
|
||||
|
||||
#### Deploy Script
|
||||
- Added `set -eo pipefail` for pipe error detection
|
||||
- Fixed duplicate variable initialization
|
||||
- Fail on missing binary in --both path (was silently ignored)
|
||||
- Added post-deploy health check on .198
|
||||
|
||||
### Added
|
||||
|
||||
#### Cross-Node Test Suite
|
||||
- US-08: DWN sync tests — 50/50 pass (register, write, sync, query bidirectional)
|
||||
- US-10: Backup/restore tests — 80/80 pass (create, list, verify, delete × 10 × 2 nodes)
|
||||
- US-15: Boot recovery tests — .228 9/9 pass (32/32 containers survive 3 reboots)
|
||||
- `trigger_sync_and_wait()` helper for polling async DWN sync
|
||||
|
||||
#### did:dht Integration Planning
|
||||
- Architecture document: `docs/did-dht-integration.md`
|
||||
- BEP-44 mutable DHT items, DNS packet encoding, z-base-32 identifiers
|
||||
- Publication/resolution flows, `mainline` crate selection, security notes
|
||||
|
||||
#### DWN Protocol Definitions
|
||||
- 4 Archipelago DWN protocols documented in `docs/dwn-protocols.md`
|
||||
- Node Identity Announcements (public)
|
||||
- File Sharing Catalog (public)
|
||||
- Federation State (private)
|
||||
- App Deployment Requests (private)
|
||||
- Auto-registration of all 4 protocols on backend startup
|
||||
|
||||
#### Deploy Script Improvements
|
||||
- `--dry-run` flag shows what would be deployed without executing
|
||||
- Works with all other flags (--live, --both, --frontend-only)
|
||||
|
||||
#### ISO/First-Boot Improvements
|
||||
- Auto-create swap file on first boot (50% RAM, min 2GB, max 8GB)
|
||||
- Tiered container startup ordering in first-boot script
|
||||
- Tier 1: Databases, Tier 2: Core Services (5s delay), Tier 3: Applications (5s delay)
|
||||
|
||||
### Security
|
||||
|
||||
#### Backend Hardening
|
||||
- Rate limiting on federation endpoints (join 5/60s, invite 10/300s)
|
||||
- DWN message data size limit (10MB max)
|
||||
- Container security: cap-drop ALL, no-new-privileges, per-app memory limits
|
||||
- Input validation: path traversal protection on identity/DID endpoints
|
||||
- Error sanitization: internal paths stripped from error messages
|
||||
|
||||
## [1.1.0] - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
||||
#### Nostr Identity in Onboarding
|
||||
- Auto-generate secp256k1 Nostr keypair during identity creation
|
||||
- Onboarding shows both DID (`did:key:z...`) and Nostr ID (`npub1...`) with copy buttons
|
||||
- Real Ed25519 signature verification in onboarding verify step
|
||||
- Real encrypted backup creation in onboarding backup step
|
||||
|
||||
#### NIP-07 Iframe Signing
|
||||
- `nostr-provider.js` injected into all proxied iframe apps via nginx `sub_filter`
|
||||
- `window.nostr` interface: `getPublicKey()`, `signEvent()`, `getRelays()`
|
||||
- Signing consent modal with "Remember for this app" option
|
||||
- `node.nostr-sign` RPC endpoint — signs events with node-level Nostr key
|
||||
- NIP-04 and NIP-44 encrypt/decrypt RPC endpoints for iframe apps
|
||||
- noStrudel Nostr client added to marketplace as iframe app
|
||||
|
||||
#### File Sharing Across Nodes
|
||||
- Content catalog with add/remove/browse over Tor
|
||||
- Three access modes: `free`, `peers_only` (DID-authenticated), `paid` (cashu tokens)
|
||||
- Availability controls: `AllPeers`, `Nobody`, `Specific` (DID allowlist)
|
||||
- Peer Files view in Cloud page for browsing federated peers' shared content
|
||||
- Content download from peers via Tor SOCKS proxy
|
||||
|
||||
#### DWN Multi-Node Sync
|
||||
- Bidirectional DWN message replication over Tor between federated nodes
|
||||
- Protocol and message sync via `/dwn` HTTP endpoint
|
||||
- DWN sync status in Federation dashboard with "Sync Now" button
|
||||
- DWN management section in Web5 page (protocols, messages, sync targets)
|
||||
|
||||
#### Node Visualization Map
|
||||
- D3.js force-directed network topology graph
|
||||
- Nodes colored by trust level (green/amber/red), opacity by online status
|
||||
- Self node centered, draggable peer nodes with tooltips
|
||||
- List/Map tab switcher in Federation page with localStorage persistence
|
||||
|
||||
#### Tor Address Rotation
|
||||
- `tor.rotate-service` RPC: generates new .onion address with 24h transition
|
||||
- Automatic propagation to Nostr relays and federation peers
|
||||
- `tor.cleanup-rotated` for expired transition directories
|
||||
- Per-app Tor toggle (`tor.toggle-app`) to enable/disable Tor per service
|
||||
- Tor management UI in Settings with rotate button and per-app toggles
|
||||
|
||||
#### Boot Container Recovery
|
||||
- All stopped containers automatically started on backend boot
|
||||
- Fixes clean reboot scenario where PID marker was removed by systemd
|
||||
|
||||
#### Monitoring & Testing
|
||||
- Federation health check script (cron every 5min, CSV + JSON output)
|
||||
- Uptime monitor with authenticated RPC access
|
||||
- `test-first-install.sh` — 8-check post-install verification
|
||||
- `test-nip07.sh` — 11-check NIP-07 signing validation
|
||||
- `test-tor-rotation.sh` — 10-check Tor rotation lifecycle
|
||||
- `test-integration-full.sh` — 23-check full integration test
|
||||
- `test-failure-recovery.sh` — 5-scenario failure injection + recovery
|
||||
|
||||
### Fixed
|
||||
- Health monitor webhook gate no longer blocks auto-restart and notifications
|
||||
- Monitoring alerts now trigger webhook delivery (DiskWarning, ContainerCrash)
|
||||
- Tor hostname reading with `tor-hostnames` readable cache (0700 system Tor dirs)
|
||||
- Tor rotation clears hostname cache before reading new address
|
||||
- Rotation restarts system Tor (not just archy-tor container)
|
||||
- NIP-07 signing uses node-level key (matches `getPublicKey()`)
|
||||
- DWN sync URL uses port 80 (nginx/Tor) instead of 5678
|
||||
- DWN `/dwn` POST endpoint allows unauthenticated peer sync
|
||||
- DWN message handler supports both single and batch message formats
|
||||
|
||||
## [0.8.0-rc1] - 2026-03-11
|
||||
|
||||
### Added
|
||||
|
||||
48
CLAUDE.md
48
CLAUDE.md
@@ -8,6 +8,54 @@ Archipelago is a **Bitcoin Node OS** — a bootable, self-sovereign personal ser
|
||||
**Target OS**: Debian 12 (Bookworm) — x86_64 and ARM64
|
||||
**Current version**: 0.1.0
|
||||
|
||||
---
|
||||
|
||||
## BETA FREEZE — ACTIVE (2026-03-18)
|
||||
|
||||
**Goal: Ship a flawless beta that works perfectly on every machine we install it on.**
|
||||
|
||||
We are in **beta stabilization mode**. The current feature set is LOCKED. Every session must push toward this goal.
|
||||
|
||||
### Pipeline
|
||||
|
||||
```
|
||||
PHASE 1: Feature Testing (internal) ← WE ARE HERE
|
||||
↓ Gate: every feature works, bugs fixed, security hardened, ISO verified
|
||||
PHASE 2: User Testing (real users on real hardware we don't control)
|
||||
↓ Gate: user-reported issues resolved, telemetry shows stable fleet
|
||||
PHASE 3: Beta Live (public release)
|
||||
```
|
||||
|
||||
### What IS allowed
|
||||
- Bug fixes for existing features
|
||||
- Security hardening and testing
|
||||
- Beta telemetry / node reporting (TASK-12 — needed for user testing)
|
||||
- UI/layout rearrangements (moving things around, improving flow)
|
||||
- Boot screen completion (FEATURE-4 — already in progress)
|
||||
- Testing all features end-to-end on fresh installs
|
||||
- Performance and reliability improvements to existing code
|
||||
- ISO build hardening
|
||||
|
||||
### What is NOT allowed
|
||||
- New features (watch-only wallet, mesh balance check, etc. are POST-BETA)
|
||||
- New app integrations
|
||||
- New backend modules or RPC endpoints (unless fixing existing bugs or beta telemetry)
|
||||
- New dependencies (unless required for beta infrastructure)
|
||||
- Scope creep of any kind
|
||||
|
||||
### Status tracking
|
||||
- **Progress tracker**: `docs/BETA-PROGRESS.md` — updated every session
|
||||
- **Beta checklist**: `docs/BETA-RELEASE-CHECKLIST.md` — the acceptance criteria
|
||||
- **Master plan**: `docs/MASTER_PLAN.md` — phased roadmap (Phase 1/2/3)
|
||||
|
||||
### Session protocol
|
||||
1. Read `docs/BETA-PROGRESS.md` at start of every session
|
||||
2. Report current phase and status before starting work
|
||||
3. Work only on current-phase items
|
||||
4. Update `docs/BETA-PROGRESS.md` at end of every session with what changed
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
|
||||
161
CONTRIBUTING.md
Normal file
161
CONTRIBUTING.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Contributing to Archipelago
|
||||
|
||||
Thank you for your interest in contributing to Archipelago! This document covers the process for contributing code, reporting bugs, and submitting apps.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Be respectful. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/archy.git`
|
||||
3. Set up the dev environment (see `docs/development-setup.md`)
|
||||
4. Create a feature branch: `git checkout -b feature/your-feature`
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Frontend (Vue.js)
|
||||
|
||||
```bash
|
||||
cd neode-ui
|
||||
npm install
|
||||
npm start # Dev server on :8100
|
||||
npm run type-check # TypeScript validation
|
||||
npm run build # Production build
|
||||
npm test # Run tests
|
||||
```
|
||||
|
||||
### Backend (Rust)
|
||||
|
||||
Build on a Linux server (Debian 12), **not** macOS:
|
||||
|
||||
```bash
|
||||
cargo clippy --all-targets --all-features
|
||||
cargo fmt --all
|
||||
cargo test --all-features
|
||||
```
|
||||
|
||||
### Deploy to dev server
|
||||
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
### Frontend (TypeScript + Vue)
|
||||
|
||||
- `<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
|
||||
|
||||
### Backend (Rust)
|
||||
|
||||
- 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
|
||||
|
||||
### General
|
||||
|
||||
- Functions under 50 lines, single responsibility
|
||||
- Comment WHY not WHAT
|
||||
- Remove dead code — never comment it out
|
||||
- No `TODO`/`FIXME` in commits
|
||||
|
||||
## Commit Format
|
||||
|
||||
```
|
||||
type: description
|
||||
```
|
||||
|
||||
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`
|
||||
|
||||
Examples:
|
||||
- `feat: add backup scheduling to settings page`
|
||||
- `fix: handle WiFi connection timeout gracefully`
|
||||
- `test: add unit tests for RPC client retry logic`
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
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
|
||||
|
||||
### 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
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the same license as the project.
|
||||
346
README.md
346
README.md
@@ -1,292 +1,130 @@
|
||||
# 🏝️ Archipelago
|
||||
# Archipelago
|
||||
|
||||
> Your Sovereign Personal Server
|
||||
> Self-Sovereign Bitcoin Node OS
|
||||
|
||||
**Archipelago** is a next-generation Bitcoin Node OS for macOS that combines the power of Bitcoin Core, Lightning Network, and modern self-hosted applications in a beautiful, easy-to-use interface.
|
||||
**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, and Web5 identity through a modern web interface.
|
||||
|
||||
[](https://www.apple.com/macos/)
|
||||
[](https://www.debian.org/)
|
||||
[](LICENSE)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[](https://vuejs.org/)
|
||||
[]()
|
||||
|
||||
## ✨ Features
|
||||
## Features
|
||||
|
||||
### 🟠 Bitcoin & Lightning
|
||||
- **Bitcoin Core** - Full node with custom UI (regtest/testnet/mainnet)
|
||||
- **LND** - Lightning Network Daemon for instant payments
|
||||
- **BTCPay Server** - Self-hosted payment processing
|
||||
- **Mempool** - Beautiful blockchain explorer
|
||||
### Bitcoin Infrastructure
|
||||
- **Bitcoin Knots** full node with pruning support
|
||||
- **LND** Lightning Network daemon with channel management
|
||||
- **Electrs** Electrum server for wallet connectivity
|
||||
- **BTCPay Server** for accepting Bitcoin payments
|
||||
- **Mempool** block explorer and fee estimator
|
||||
- **Fedimint** federation guardian and gateway
|
||||
|
||||
### 🚀 Self-Hosted Apps
|
||||
- **Nextcloud** - Cloud storage and file management
|
||||
- **Penpot** - Open-source design and prototyping
|
||||
- **Endurain** - Fitness tracking platform
|
||||
- **Home Assistant** - Home automation hub
|
||||
- **Grafana** - Metrics and monitoring
|
||||
- **OnlyOffice** - Document editing suite
|
||||
- **SearXNG** - Privacy-respecting search
|
||||
- **Morphos** - File conversion utility
|
||||
### Self-Hosted Apps (20+)
|
||||
Storage (File Browser, Immich, Nextcloud), Productivity (Penpot, OnlyOffice, Vaultwarden), Media (Jellyfin), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), and more.
|
||||
|
||||
### 🎨 Modern UI
|
||||
- **Glassmorphism Design** - Beautiful, modern interface
|
||||
- **Real-time Updates** - WebSocket-powered live data
|
||||
- **Responsive Layout** - Works on desktop and mobile
|
||||
- **Dark Theme** - Easy on the eyes
|
||||
- **Progressive Web App** - Install as native app
|
||||
### Web5 Identity
|
||||
- DID-based digital identity (Ed25519 + secp256k1)
|
||||
- Verifiable Credentials issuance and verification
|
||||
- Decentralized Web Node (DWN) for data sync
|
||||
- Nostr relay integration for node discovery
|
||||
|
||||
### 🔧 Technical
|
||||
- **Native Rust Backend** - Fast, secure, efficient
|
||||
- **Vue.js Frontend** - Modern reactive UI
|
||||
- **Docker Integration** - Seamless container management
|
||||
- **One-Click Launch** - Start apps with a single click
|
||||
- **Auto-Discovery** - Automatically detects running containers
|
||||
### Security
|
||||
- AES-256-GCM encrypted secrets at rest
|
||||
- Container isolation: read-only root, capability dropping, non-root user
|
||||
- TOTP two-factor authentication
|
||||
- Per-endpoint rate limiting and input validation
|
||||
- AppArmor profiles for container confinement
|
||||
|
||||
## 🚀 Quick Start
|
||||
## Quick Start
|
||||
|
||||
### Build ISO from Source
|
||||
### Install from ISO
|
||||
|
||||
```bash
|
||||
# One command to build everything and create flashable ISO
|
||||
./build-iso-complete.sh --remote archipelago@192.168.1.228
|
||||
1. Download the ISO for your architecture (x86_64 or ARM64)
|
||||
2. Flash to USB drive with Balena Etcher or `dd`
|
||||
3. Boot from USB on target hardware
|
||||
4. Follow the automated installer
|
||||
5. Access the web UI at `http://<device-ip>`
|
||||
6. Set your password and start the onboarding wizard
|
||||
|
||||
# Flash to USB
|
||||
./flash-to-usb.sh /dev/diskN
|
||||
```
|
||||
### Supported Hardware
|
||||
|
||||
**📘 See [BUILD-GUIDE.md](BUILD-GUIDE.md) for detailed build instructions.**
|
||||
| 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 full Bitcoin node)
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
- macOS 10.15 (Catalina) or later
|
||||
- 8GB RAM minimum (16GB recommended)
|
||||
- 20GB free disk space
|
||||
- [Docker Desktop](https://www.docker.com/products/docker-desktop)
|
||||
- Rust stable toolchain
|
||||
- Node.js 20+
|
||||
- Linux dev server (Debian 12) for backend builds
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Download the latest release**
|
||||
```bash
|
||||
# Download from GitHub Releases
|
||||
https://github.com/[your-repo]/archipelago/releases/latest
|
||||
```
|
||||
|
||||
2. **Install Docker Desktop**
|
||||
- Download from https://www.docker.com/products/docker-desktop
|
||||
- Install and start Docker Desktop
|
||||
|
||||
3. **Install Archipelago**
|
||||
- Open the DMG file
|
||||
- Drag Archipelago to Applications
|
||||
- Launch from Applications folder
|
||||
|
||||
4. **Access the Dashboard**
|
||||
- Open http://localhost:8100 in your browser
|
||||
- Login with default credentials (change immediately!)
|
||||
|
||||
See [QUICKSTART.md](QUICKSTART.md) for detailed instructions.
|
||||
|
||||
## 🏗️ Building from Source
|
||||
|
||||
### Development Setup (macOS/Docker)
|
||||
### Frontend Development
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/[your-repo]/archipelago.git
|
||||
cd archipelago
|
||||
|
||||
# Install Rust
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# Install Node.js (using Homebrew)
|
||||
brew install node
|
||||
|
||||
# Build and run in development mode
|
||||
./start-docker-apps.sh
|
||||
cd neode-ui
|
||||
npm install
|
||||
npm start # Dev server on http://localhost:8100
|
||||
npm run type-check # TypeScript validation
|
||||
npm test # Run 515+ tests
|
||||
npm run build # Production build
|
||||
```
|
||||
|
||||
### Production Builds for Hardware
|
||||
|
||||
Archipelago supports building bootable Debian-based OS images:
|
||||
### Deploy to Server
|
||||
|
||||
```bash
|
||||
cd image-recipe
|
||||
|
||||
# Build bootable ISO
|
||||
./build-debian-iso.sh
|
||||
|
||||
# Write to USB
|
||||
./write-usb-dd.sh /dev/diskN
|
||||
./scripts/deploy-to-target.sh --live # Deploy to dev server
|
||||
./scripts/deploy-to-target.sh --both # Deploy to both servers
|
||||
```
|
||||
|
||||
**Supported Hardware:**
|
||||
- 🖥️ **Start9 Server Pure** - Intel i7-10710U, 32-64GB RAM, 2-4TB NVMe
|
||||
- 🖥️ **HP ProDesk 400 G4 DM** - Intel varies, 8GB+ RAM, 128GB+ SSD
|
||||
- 🖥️ **Dell OptiPlex** - Intel varies, 8GB+ RAM, 128GB+ SSD
|
||||
- 🖥️ **Generic x86_64** - Any x86_64 hardware with UEFI
|
||||
### Build ISO
|
||||
|
||||
See [image-recipe/README.md](image-recipe/README.md) for detailed build instructions.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- **[Quick Start Guide](QUICKSTART.md)** - Get started in minutes
|
||||
- **[Build Instructions](BUILD_MACOS.md)** - Build from source
|
||||
- **[Deployment Checklist](DEPLOYMENT_CHECKLIST.md)** - Release process
|
||||
- **[Architecture](docs/architecture.md)** - System design
|
||||
- **[Changelog](CHANGELOG.md)** - Version history
|
||||
|
||||
## 🗺️ Project Structure
|
||||
|
||||
```
|
||||
archipelago/
|
||||
├── core/ # Rust backend
|
||||
│ ├── archipelago/ # Main backend binary
|
||||
│ ├── container/ # Docker integration
|
||||
│ ├── security/ # Security modules
|
||||
│ └── performance/ # Performance optimization
|
||||
├── neode-ui/ # Vue.js frontend
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # Page components
|
||||
│ │ ├── components/ # UI components
|
||||
│ │ └── stores/ # State management
|
||||
│ └── public/ # Static assets
|
||||
├── docker/ # Docker UI assets
|
||||
│ ├── bitcoin-ui/ # Bitcoin Core UI
|
||||
│ └── lnd-ui/ # LND UI
|
||||
├── docker-compose.yml # Container orchestration
|
||||
└── build-macos-production.sh # Production build script
|
||||
```bash
|
||||
ssh archipelago@<server>
|
||||
cd ~/archy/image-recipe
|
||||
sudo ./build-auto-installer-iso.sh # x86_64
|
||||
sudo ARCH=arm64 ./build-auto-installer-iso.sh # ARM64
|
||||
```
|
||||
|
||||
## 🐳 Docker Apps
|
||||
## Architecture
|
||||
|
||||
All apps run in isolated Docker containers with automatic health monitoring:
|
||||
```
|
||||
Debian 12 (Bookworm)
|
||||
├── Podman (rootless containers)
|
||||
├── Nginx (reverse proxy + security headers)
|
||||
├── Rust Backend (JSON-RPC API on port 5678)
|
||||
│ ├── core/archipelago/ — RPC endpoints, state, identity
|
||||
│ ├── core/container/ — Podman client, manifests, health
|
||||
│ └── core/security/ — AppArmor, secrets, image verification
|
||||
└── Vue 3 Frontend (Composition API + TypeScript + Pinia)
|
||||
```
|
||||
|
||||
| App | Port | Description |
|
||||
|-----|------|-------------|
|
||||
| Dashboard | 8100 | Main Archipelago UI |
|
||||
| Bitcoin Core | 18443-18444 | Bitcoin RPC |
|
||||
| Bitcoin UI | 18445 | Custom Bitcoin interface |
|
||||
| LND | 10009 | Lightning gRPC |
|
||||
| LND UI | 8085 | Custom LND interface |
|
||||
| BTCPay Server | 8082 | Payment processing |
|
||||
| Mempool | 8080 | Blockchain explorer |
|
||||
| Penpot | 9001 | Design platform |
|
||||
| Endurain | 8084 | Fitness tracking |
|
||||
| Morphos | 8081 | File converter |
|
||||
| Nextcloud | 8086 | Cloud storage |
|
||||
| Grafana | 8083 | Monitoring |
|
||||
| Home Assistant | 8123 | Home automation |
|
||||
## Documentation
|
||||
|
||||
## 🔐 Security
|
||||
- [Architecture](docs/architecture.md) — System design
|
||||
- [Developer Guide](docs/developer-guide.md) — Contributing guide
|
||||
- [App Developer Guide](docs/app-developer-guide.md) — Writing app manifests
|
||||
- [App Manifest Spec](docs/app-manifest-spec.md) — YAML manifest format
|
||||
- [User Guide](docs/user-guide.md) — End-user documentation
|
||||
- [Release Notes](RELEASE-NOTES-v1.0.0.md) — v1.0.0 release notes
|
||||
- [v1.1 Roadmap](docs/roadmap-v1.1.md) — Upcoming features
|
||||
- [v2.0 Roadmap](docs/roadmap-v2.0.md) — Long-term vision
|
||||
|
||||
### Default Security Measures
|
||||
- ✅ Localhost-only by default (127.0.0.1)
|
||||
- ✅ Container isolation (Docker networks)
|
||||
- ✅ No root privileges required
|
||||
- ✅ Encrypted data storage
|
||||
- ✅ Session-based authentication
|
||||
## Contributing
|
||||
|
||||
### Recommended Practices
|
||||
- 🔑 Change default passwords immediately
|
||||
- 🔥 Enable macOS firewall
|
||||
- 🔄 Keep Docker and Archipelago updated
|
||||
- 💾 Backup data regularly
|
||||
- 🚫 Don't expose ports without VPN
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`feature/description`)
|
||||
3. Follow the coding standards in [CLAUDE.md](CLAUDE.md)
|
||||
4. Submit a pull request with tests
|
||||
|
||||
## 🤝 Contributing
|
||||
## License
|
||||
|
||||
We welcome contributions! Here's how:
|
||||
[MIT License](LICENSE)
|
||||
|
||||
1. **Fork the repository**
|
||||
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
|
||||
3. **Commit your changes**: `git commit -m 'Add amazing feature'`
|
||||
4. **Push to the branch**: `git push origin feature/amazing-feature`
|
||||
5. **Open a Pull Request**
|
||||
## Acknowledgments
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
## 🐛 Bug Reports
|
||||
|
||||
Found a bug? Please open an issue with:
|
||||
- macOS version
|
||||
- Docker version
|
||||
- Archipelago version
|
||||
- Steps to reproduce
|
||||
- Error logs
|
||||
|
||||
## 💬 Community
|
||||
|
||||
- **GitHub Discussions**: Ask questions and share ideas
|
||||
- **Discord**: [Join our server] (coming soon)
|
||||
- **Twitter**: [@archipelago_os] (coming soon)
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
### v0.2.0 (Q2 2026)
|
||||
- [ ] Auto-update system
|
||||
- [ ] Multi-user support
|
||||
- [ ] Enhanced Bitcoin Core controls
|
||||
- [ ] Lightning Network autopilot
|
||||
- [ ] Backup/restore functionality
|
||||
|
||||
### v0.3.0 (Q3 2026)
|
||||
- [ ] Native container runtime (no Docker Desktop)
|
||||
- [ ] iOS companion app
|
||||
- [ ] Hardware wallet integration
|
||||
- [ ] Tor integration
|
||||
- [ ] VPN/Tailscale support
|
||||
|
||||
### v1.0.0 (Q4 2026)
|
||||
- [ ] Mac App Store release
|
||||
- [ ] Windows support
|
||||
- [ ] Linux support
|
||||
- [ ] Plugin system
|
||||
- [ ] Decentralized app marketplace
|
||||
|
||||
## 📊 System Requirements
|
||||
|
||||
### Minimum
|
||||
- macOS 10.15 (Catalina)
|
||||
- 8GB RAM
|
||||
- 20GB disk space
|
||||
- Intel or Apple Silicon CPU
|
||||
|
||||
### Recommended
|
||||
- macOS 12.0 (Monterey) or later
|
||||
- 16GB RAM
|
||||
- 50GB+ disk space (for blockchain)
|
||||
- SSD storage
|
||||
- Fast internet
|
||||
|
||||
## 📜 License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
Built with amazing open-source projects:
|
||||
- [Rust](https://www.rust-lang.org/) - Systems programming language
|
||||
- [Vue.js](https://vuejs.org/) - Frontend framework
|
||||
- [Docker](https://www.docker.com/) - Container runtime
|
||||
- [Bitcoin Core](https://bitcoin.org/) - Bitcoin full node
|
||||
- [LND](https://lightning.engineering/) - Lightning Network
|
||||
- [Debian](https://www.debian.org/) - Stable Linux foundation
|
||||
- And many more...
|
||||
|
||||
## 💖 Support the Project
|
||||
|
||||
If you find Archipelago useful:
|
||||
- ⭐ Star the repository
|
||||
- 🐦 Share on social media
|
||||
- 🐛 Report bugs and request features
|
||||
- 💻 Contribute code
|
||||
- ☕ [Buy us a coffee] (coming soon)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Built with ❤️ by the Archipelago team**
|
||||
|
||||
[Website](https://archipelago.os) • [Documentation](./docs) • [Twitter](https://twitter.com/archipelago_os)
|
||||
|
||||
</div>
|
||||
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/), [Debian](https://www.debian.org/)
|
||||
|
||||
84
RELEASE-NOTES-v0.5.0-beta.md
Normal file
84
RELEASE-NOTES-v0.5.0-beta.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Archipelago v0.5.0-beta Release Notes
|
||||
|
||||
**Release Date**: March 2026
|
||||
**Target Platform**: Debian 12 (Bookworm) — x86_64 and ARM64
|
||||
|
||||
## Overview
|
||||
|
||||
This is the first public beta of Archipelago, a self-sovereign Bitcoin Node OS. Flash it to a USB, install on any x86_64 or ARM64 machine, and manage your personal server through a modern web interface.
|
||||
|
||||
## What's Included
|
||||
|
||||
### Core System
|
||||
- Rust backend with RPC API and WebSocket real-time updates
|
||||
- Vue 3 frontend with glassmorphism UI design
|
||||
- Automated Podman container management
|
||||
- Nginx reverse proxy with HTTPS (self-signed cert)
|
||||
- Tor hidden services for all apps
|
||||
|
||||
### App Store (16+ Apps)
|
||||
- **Bitcoin Stack**: Bitcoin Knots, Electrs, LND, BTCPay Server, Mempool, Fedimint
|
||||
- **Storage**: File Browser, Immich, PhotoPrism
|
||||
- **Productivity**: Penpot, SearXNG
|
||||
- **AI**: Ollama (local LLMs)
|
||||
- **Network**: Nostr Relay, Nginx Proxy Manager, Tailscale, Home Assistant
|
||||
- **Platform**: IndeedHub
|
||||
|
||||
### Security
|
||||
- AES-256-GCM encrypted secrets on disk
|
||||
- Session management: 24h inactivity expiry, max 5 concurrent sessions
|
||||
- TOTP two-factor authentication with backup codes
|
||||
- Container hardening: read-only root, no new privileges, dropped capabilities
|
||||
- Pinned container image versions (no `:latest` tags)
|
||||
- Login rate limiting (5 attempts per 60 seconds per IP)
|
||||
- Path traversal prevention (nginx + client-side)
|
||||
- Cookie-based auth (no tokens in URLs)
|
||||
|
||||
### Identity & Web5
|
||||
- Decentralized Identifier (DID) generation
|
||||
- Identity backup/restore
|
||||
- Nostr relay support
|
||||
|
||||
### Performance
|
||||
- Backend startup: ~100ms
|
||||
- Frontend bundle: ~105 KB gzipped
|
||||
- WebSocket heartbeat with 30s ping/pong
|
||||
- Exponential backoff reconnection (max 30s)
|
||||
- Real-time install progress via WebSocket
|
||||
- Server-side 5-minute inactivity timeout for stale connections
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. **ARM64 ISO**: ARM64 builds may require manual testing — primary testing is on x86_64
|
||||
2. **Bitcoin Initial Sync**: First blockchain sync takes 1-7 days depending on hardware
|
||||
3. **Self-signed HTTPS**: Browser shows certificate warning on first visit (expected)
|
||||
4. **Restore from Backup**: Not yet implemented in onboarding flow
|
||||
5. **Connect to Existing Server**: Not yet implemented in onboarding flow
|
||||
6. **Immich**: Stack installation may take 5+ minutes due to multiple container images
|
||||
7. **Memory**: Running all apps simultaneously requires 16+ GB RAM
|
||||
8. **Disk Space**: Full Bitcoin node + all apps requires 800+ GB
|
||||
|
||||
## Upgrade Path
|
||||
|
||||
This is a beta release. No upgrade path from beta to stable is guaranteed. Back up your data before installing.
|
||||
|
||||
## System Requirements
|
||||
|
||||
| Component | Minimum | Recommended |
|
||||
|-----------|---------|-------------|
|
||||
| CPU | 4 cores | 8+ cores |
|
||||
| RAM | 16 GB | 32 GB |
|
||||
| Storage | 500 GB SSD | 2 TB NVMe |
|
||||
| Network | Ethernet | Gigabit Ethernet |
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Download the ISO for your architecture
|
||||
2. Flash to USB with balenaEtcher or `dd`
|
||||
3. Boot from USB on target hardware
|
||||
4. Auto-installer runs — follow on-screen prompts
|
||||
5. After reboot, navigate to `http://<server-ip>` in your browser
|
||||
6. Complete the onboarding wizard
|
||||
7. Start installing apps from the App Store
|
||||
|
||||
See [User Guide](docs/user-guide.md) for detailed instructions.
|
||||
111
RELEASE-NOTES-v1.0.0.md
Normal file
111
RELEASE-NOTES-v1.0.0.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Archipelago v1.0.0 Release Notes
|
||||
|
||||
**Release Date**: March 2026
|
||||
**Target Platform**: Debian 12 (Bookworm) — x86_64 and ARM64
|
||||
|
||||
## What is Archipelago?
|
||||
|
||||
Archipelago is a self-sovereign Bitcoin Node OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage your personal server through a modern web interface. Run Bitcoin infrastructure, self-hosted apps, and Web5 identity — all from hardware you control.
|
||||
|
||||
## Key Features
|
||||
|
||||
### Bitcoin Infrastructure
|
||||
- **Bitcoin Knots** full node with pruning support
|
||||
- **LND** Lightning Network daemon with channel management UI
|
||||
- **Electrs** Electrum server for wallet connectivity
|
||||
- **BTCPay Server** for accepting Bitcoin payments
|
||||
- **Mempool** block explorer and fee estimator
|
||||
- **Fedimint** federation guardian and gateway
|
||||
|
||||
### Self-Hosted Apps (20+)
|
||||
- **Storage**: File Browser, Immich, PhotoPrism, Nextcloud
|
||||
- **Productivity**: Penpot, OnlyOffice, Vaultwarden
|
||||
- **Media**: Jellyfin
|
||||
- **Search**: SearXNG (private search)
|
||||
- **AI**: Ollama (local LLMs with Claude, GPT, and open models)
|
||||
- **Network**: Tailscale VPN, Nginx Proxy Manager, Uptime Kuma
|
||||
- **Home**: Home Assistant
|
||||
- **Platform**: IndeedHub, Grafana monitoring
|
||||
|
||||
### Web5 Identity
|
||||
- DID-based digital identity (Ed25519 + secp256k1 dual key)
|
||||
- Verifiable Credentials issuance and verification
|
||||
- Decentralized Web Node (DWN) for data sync
|
||||
- Nostr relay integration for node discovery
|
||||
|
||||
### Federation
|
||||
- DID-authenticated peer-to-peer federation
|
||||
- Remote node monitoring and management
|
||||
- Bilateral trust with single-use invite codes
|
||||
- Tor hidden services for private communication
|
||||
|
||||
### Security
|
||||
- AES-256-GCM encrypted secrets at rest
|
||||
- Container isolation: read-only root, capability dropping, non-root user
|
||||
- TOTP two-factor authentication with backup codes
|
||||
- Session management: HttpOnly cookies, SameSite=Strict, CSRF tokens
|
||||
- Rate limiting on sensitive endpoints
|
||||
- AppArmor profiles for container confinement
|
||||
- Per-endpoint input validation
|
||||
|
||||
### System
|
||||
- Rust backend with JSON-RPC API (<1ms response time)
|
||||
- Vue 3 frontend with glassmorphism design
|
||||
- WebSocket real-time updates
|
||||
- Automated OTA updates with rollback
|
||||
- Tor hidden services for all apps
|
||||
- Goal-based onboarding wizard
|
||||
- Kiosk mode for dedicated hardware
|
||||
|
||||
## Supported Hardware
|
||||
|
||||
- **x86_64**: Any 64-bit PC, Intel NUC, mini PCs
|
||||
- **ARM64**: Raspberry Pi 5, other ARM64 SBCs
|
||||
- **Minimum**: 4GB RAM, 32GB storage (500GB+ recommended for Bitcoin)
|
||||
- **Recommended**: 8GB+ RAM, 1TB+ NVMe SSD
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the ISO for your architecture
|
||||
2. Flash to USB drive (use Balena Etcher or `dd`)
|
||||
3. Boot from USB on target hardware
|
||||
4. Follow the automated installer
|
||||
5. Access the web UI at `http://<device-ip>`
|
||||
6. Set your password and start the onboarding wizard
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Bitcoin initial block download takes 3-7 days depending on hardware
|
||||
- Some apps (BTCPay Server, Home Assistant) open in new tab due to X-Frame-Options
|
||||
- ARM64 builds may have slower container pulls due to less cached registry content
|
||||
- Tor hidden service generation takes 1-2 minutes on first boot
|
||||
|
||||
## Upgrade from Beta
|
||||
|
||||
If upgrading from v0.5.0-beta:
|
||||
1. Back up your data via Settings > Backup
|
||||
2. The OTA update system will handle the upgrade automatically
|
||||
3. If OTA fails, reflash with the v1.0.0 ISO (app data is preserved on separate partition)
|
||||
|
||||
## Security Model
|
||||
|
||||
Archipelago follows defense-in-depth:
|
||||
- **Network**: Nginx reverse proxy, Tor hidden services, VPN support
|
||||
- **Application**: Container isolation with Podman (rootless)
|
||||
- **Data**: AES-256-GCM encryption for secrets, 0600 file permissions
|
||||
- **Auth**: Argon2 password hashing, TOTP 2FA, session rotation
|
||||
- **Updates**: SHA-256 verified downloads with rollback capability
|
||||
|
||||
See `docs/adr/` for architectural decision records on security choices.
|
||||
|
||||
## Contributing
|
||||
|
||||
Archipelago is open source. To contribute:
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`feature/description`)
|
||||
3. Follow the coding standards in `CLAUDE.md`
|
||||
4. Submit a pull request with tests
|
||||
|
||||
## License
|
||||
|
||||
MIT License. See `LICENSE` for details.
|
||||
@@ -18,7 +18,10 @@ app:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated # No outbound network — all data comes via context broker
|
||||
apparmor_profile: aiui
|
||||
|
||||
ports:
|
||||
- host: 5180
|
||||
|
||||
@@ -20,6 +20,9 @@ app:
|
||||
security:
|
||||
capabilities: [] # No special capabilities needed
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: bitcoin-core
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ app:
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: btcpay
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ app:
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: core-lightning
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ app:
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: did-wallet
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ app:
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: endurain
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ app:
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: fedimint
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ app:
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: grafana
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ app:
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: false # Home Assistant needs write access
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: host # Requires host network for device discovery
|
||||
apparmor_profile: home-assistant
|
||||
|
||||
|
||||
78
apps/indeedhub/Dockerfile
Normal file
78
apps/indeedhub/Dockerfile
Normal file
@@ -0,0 +1,78 @@
|
||||
# Multi-stage Dockerfile for Indeehub Frontend (Next.js)
|
||||
# Build: podman build -t localhost/indeedhub:latest -f apps/indeedhub/Dockerfile /path/to/indeehub-frontend
|
||||
# Run: podman run -d --name indeedhub -p 8190:3000 localhost/indeedhub:latest
|
||||
|
||||
# ── Stage 1: Dependencies ──
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
# ── Stage 2: Build ──
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Inject standalone output mode for containerized deployment
|
||||
RUN sed -i 's/reactStrictMode: true,/reactStrictMode: true, output: "standalone",/' next.config.js
|
||||
|
||||
# Build-time environment — connects to Indeehub production services
|
||||
ENV NEXT_PUBLIC_APP_ENVIRONMENT=production
|
||||
ENV NEXT_PUBLIC_APP_URL=http://localhost:8190
|
||||
ENV NEXT_PUBLIC_API_URL=https://staging-api.indeehub.studio
|
||||
ENV NEXT_PUBLIC_S3_PRIVATE_BUCKET=indeehub-private
|
||||
ENV NEXT_PUBLIC_S3_PUBLIC_BUCKET=indeehub-public
|
||||
ENV NEXT_PUBLIC_ENABLE_APPROVAL_FLOW=false
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Remove shaka-player .d.ts files that break the build (per package.json build script)
|
||||
RUN rm -f ./node_modules/shaka-player/dist/*.d.ts
|
||||
|
||||
# Patch: replace home page with error-resilient version that doesn't crash
|
||||
# when the Webflow landing page URL is unreachable from the container.
|
||||
RUN printf '%s\n' \
|
||||
"import axios from 'axios';" \
|
||||
"import { HomeClient } from './page.client';" \
|
||||
"" \
|
||||
"export const dynamic = 'force-dynamic';" \
|
||||
"" \
|
||||
"export default async function Home() {" \
|
||||
" try {" \
|
||||
" const response = await axios('https://indeehub-30479a.webflow.io/', { timeout: 8000 });" \
|
||||
" if (response.status !== 200) throw new Error('Bad status');" \
|
||||
" const html = String(response.data)" \
|
||||
" .replace('https://cdn.prod.website-files.com/img/favicon.ico', '/favicon.ico')" \
|
||||
" .replace('https://cdn.prod.website-files.com/img/webclip.png', '/favicon.ico');" \
|
||||
" return <HomeClient html={html} />;" \
|
||||
" } catch {" \
|
||||
" return <HomeClient html=\"<html><head><title>IndeeHub</title></head><body style='background:#000;color:#fff;font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0'><div style='text-align:center'><h1>IndeeHub</h1><p>Loading content...</p><script>setTimeout(function(){location.reload()},5000)</script></div></body></html>\" />;" \
|
||||
" }" \
|
||||
"}" > src/app/page.tsx
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 3: Runner ──
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy standalone build output
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,44 +1,53 @@
|
||||
# IndeedHub (Indeehub Prototype)
|
||||
# Indeehub — Bitcoin Documentary Streaming
|
||||
|
||||
Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.
|
||||
|
||||
Self-hosted Next.js app with Nostr identity sign-in via Archipelago's NIP-07 provider.
|
||||
|
||||
## Building the Image
|
||||
|
||||
The app image is built from the **Indeehub Prototype** project. The prototype lives at `../../Indeedhub Prototype` (relative to the archy repo).
|
||||
The app image is built from the **indeehub-frontend** project at `~/Projects/indeehub-frontend`.
|
||||
|
||||
### Option 1: Build from prototype directory
|
||||
|
||||
```bash
|
||||
cd "/path/to/Indeedhub Prototype"
|
||||
podman build -t localhost/indeedhub:latest .
|
||||
```
|
||||
|
||||
### Option 2: Use the build script
|
||||
### Option 1: Use the build script
|
||||
|
||||
```bash
|
||||
# From archy repo root
|
||||
./apps/indeedhub/build-from-prototype.sh
|
||||
```
|
||||
|
||||
### Option 3: Full deploy (build + run on server)
|
||||
### Option 2: Build from source directory
|
||||
|
||||
```bash
|
||||
cd "/path/to/Indeedhub Prototype"
|
||||
./deploy-to-archipelago.sh
|
||||
cd ~/Projects/indeehub-frontend
|
||||
podman build -t localhost/indeedhub:latest -f ~/Projects/archy/apps/indeedhub/Dockerfile .
|
||||
```
|
||||
|
||||
## Installing from My Apps
|
||||
## Installing from App Store
|
||||
|
||||
1. **Build the image** using one of the options above (the image must exist before install)
|
||||
2. Go to **Dashboard → App Store** (Marketplace)
|
||||
3. Find **Indeehub Prototype** and click **Install**
|
||||
4. The app will appear in **My Apps** once the container is running
|
||||
1. **Build the image** using one of the options above (must exist before install)
|
||||
2. Go to **Dashboard -> App Store** (Marketplace)
|
||||
3. Find **Indeehub** and click **Install**
|
||||
4. On first launch, pick a Nostr identity to sign in with
|
||||
5. The app appears in **My Apps** once the container is running
|
||||
|
||||
## Port
|
||||
|
||||
- Web UI: 7777
|
||||
- Web UI: 8190 (maps to container port 3000)
|
||||
|
||||
## Container
|
||||
|
||||
- Image: `localhost/indeedhub:latest` (built locally, not pulled from a registry)
|
||||
- Port: 7777
|
||||
- Runtime: Node.js 20 (Next.js standalone)
|
||||
- Port: 8190 -> 3000
|
||||
- Read-only root filesystem with tmpfs for /tmp and .next/cache
|
||||
|
||||
## Nostr Identity
|
||||
|
||||
On first launch, Archipelago shows a cypherpunk identity picker modal. Select which of your identities to use for NIP-07 signing. The NIP-07 provider is injected automatically via nginx proxy.
|
||||
|
||||
## Services
|
||||
|
||||
The app connects to the following external services (configured at build time):
|
||||
- Indeehub API (content, auth, streaming)
|
||||
- AWS S3 (media storage via CloudFront CDN)
|
||||
- Nostr relays (via NIP-07 provider from Archipelago)
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Build Indeehub image from the Indeehub Prototype project
|
||||
# Usage: ./build-from-prototype.sh [path-to-prototype]
|
||||
# Build Indeehub container image from the indeehub-frontend project
|
||||
# Usage: ./build-from-prototype.sh [path-to-indeehub-frontend]
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEFAULT_PROTOTYPE="$SCRIPT_DIR/../../Indeedhub Prototype"
|
||||
PROTOTYPE_DIR="${1:-$DEFAULT_PROTOTYPE}"
|
||||
DEFAULT_FRONTEND="$HOME/Projects/indeehub-frontend"
|
||||
FRONTEND_DIR="${1:-$DEFAULT_FRONTEND}"
|
||||
IMAGE_TAG="localhost/indeedhub:latest"
|
||||
|
||||
if [ ! -d "$PROTOTYPE_DIR" ]; then
|
||||
echo "❌ Indeehub Prototype not found at: $PROTOTYPE_DIR"
|
||||
echo " Set path: $0 /path/to/Indeedhub\ Prototype"
|
||||
if [ ! -d "$FRONTEND_DIR" ]; then
|
||||
echo "Indeehub frontend not found at: $FRONTEND_DIR"
|
||||
echo " Set path: $0 /path/to/indeehub-frontend"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$FRONTEND_DIR/package.json" ]; then
|
||||
echo "No package.json found in $FRONTEND_DIR — is this the right directory?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -21,10 +26,10 @@ if ! command -v podman >/dev/null 2>&1; then
|
||||
RUNTIME="docker"
|
||||
fi
|
||||
|
||||
echo "🔨 Building Indeehub from $PROTOTYPE_DIR"
|
||||
cd "$PROTOTYPE_DIR"
|
||||
$RUNTIME build -t "$IMAGE_TAG" .
|
||||
echo "Building Indeehub from $FRONTEND_DIR using $SCRIPT_DIR/Dockerfile"
|
||||
$RUNTIME build -t "$IMAGE_TAG" -f "$SCRIPT_DIR/Dockerfile" "$FRONTEND_DIR"
|
||||
|
||||
echo "✅ Built $IMAGE_TAG"
|
||||
echo "Built $IMAGE_TAG"
|
||||
echo ""
|
||||
echo "You can now install Indeehub from the App Store in Archipelago."
|
||||
echo "Or run directly: $RUNTIME run -d --name indeedhub -p 8190:3000 $IMAGE_TAG"
|
||||
|
||||
@@ -2,57 +2,60 @@ app:
|
||||
id: indeedhub
|
||||
name: Indeehub
|
||||
version: 0.1.0
|
||||
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.
|
||||
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
|
||||
category: media
|
||||
|
||||
|
||||
container:
|
||||
image: localhost/indeedhub:1.0.0
|
||||
pull_policy: never # Built locally
|
||||
|
||||
image: git.tx1138.com/lfg2025/indeedhub:latest
|
||||
pull_policy: always # Pull from registry; falls back to local build
|
||||
|
||||
dependencies:
|
||||
- storage: 500Mi
|
||||
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
cpu_limit: 2
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 500Mi
|
||||
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true # Static nginx content
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1001
|
||||
seccomp_profile: default
|
||||
network_policy: bridge
|
||||
apparmor_profile: default
|
||||
|
||||
|
||||
ports:
|
||||
- host: 7777
|
||||
container: 7777
|
||||
protocol: tcp # Web UI
|
||||
|
||||
container: 3000
|
||||
protocol: tcp # Web UI (Next.js)
|
||||
|
||||
volumes:
|
||||
- type: tmpfs
|
||||
target: /var/cache/nginx
|
||||
options: [rw,noexec,nosuid,size=10m]
|
||||
target: /tmp
|
||||
options: [rw,noexec,nosuid,size=64m]
|
||||
- type: tmpfs
|
||||
target: /var/run
|
||||
options: [rw,noexec,nosuid,size=10m]
|
||||
|
||||
target: /app/.next/cache
|
||||
options: [rw,noexec,nosuid,size=128m]
|
||||
|
||||
environment:
|
||||
- NGINX_HOST=localhost
|
||||
- NGINX_PORT=7777
|
||||
|
||||
- NODE_ENV=production
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:7777
|
||||
path: /health
|
||||
endpoint: http://localhost:3000
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: Stream Bitcoin documentaries
|
||||
description: Stream Bitcoin documentaries with Nostr identity
|
||||
type: ui
|
||||
port: 7777
|
||||
protocol: http
|
||||
@@ -69,3 +72,4 @@ app:
|
||||
- streaming
|
||||
- media
|
||||
- education
|
||||
- nostr
|
||||
|
||||
70
apps/indeedhub/push-to-registry.sh
Executable file
70
apps/indeedhub/push-to-registry.sh
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# Build and push Indeehub container image to a registry
|
||||
# Usage: ./push-to-registry.sh [version]
|
||||
#
|
||||
# Environment variables:
|
||||
# REGISTRY - Registry host (default: ghcr.io)
|
||||
# NAMESPACE - Registry namespace (default: archipelago-os)
|
||||
# RUNTIME - Container runtime (default: podman)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
FRONTEND_DIR="${INDEEHUB_FRONTEND:-$HOME/Projects/indeehub-frontend}"
|
||||
VERSION="${1:-latest}"
|
||||
REGISTRY="${REGISTRY:-git.tx1138.com}"
|
||||
NAMESPACE="${NAMESPACE:-lfg2025}"
|
||||
IMAGE_NAME="indeedhub"
|
||||
RUNTIME="${RUNTIME:-podman}"
|
||||
|
||||
FULL_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${VERSION}"
|
||||
LATEST_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:latest"
|
||||
|
||||
if [ ! -d "$FRONTEND_DIR" ]; then
|
||||
echo "Indeehub frontend not found at: $FRONTEND_DIR"
|
||||
echo "Set INDEEHUB_FRONTEND=/path/to/indeehub-frontend"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Indeehub Container Registry Push ==="
|
||||
echo "Source: $FRONTEND_DIR"
|
||||
echo "Image: $FULL_TAG"
|
||||
echo "Runtime: $RUNTIME"
|
||||
echo ""
|
||||
|
||||
# Step 1: Build for linux/amd64 (target architecture)
|
||||
echo "[1/3] Building image..."
|
||||
$RUNTIME build --platform linux/amd64 \
|
||||
-t "$FULL_TAG" \
|
||||
-t "$LATEST_TAG" \
|
||||
-t "localhost/${IMAGE_NAME}:latest" \
|
||||
-t "localhost/${IMAGE_NAME}:${VERSION}" \
|
||||
-f "$SCRIPT_DIR/Dockerfile" \
|
||||
"$FRONTEND_DIR"
|
||||
|
||||
echo "[2/3] Pushing to registry..."
|
||||
# Login check
|
||||
if ! $RUNTIME login --get-login "$REGISTRY" >/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "Not logged in to $REGISTRY."
|
||||
echo "Run: $RUNTIME login $REGISTRY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$RUNTIME push "$FULL_TAG"
|
||||
if [ "$VERSION" != "latest" ]; then
|
||||
$RUNTIME push "$LATEST_TAG"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[3/3] Done!"
|
||||
echo ""
|
||||
echo "Image pushed: $FULL_TAG"
|
||||
if [ "$VERSION" != "latest" ]; then
|
||||
echo "Also tagged: $LATEST_TAG"
|
||||
fi
|
||||
echo ""
|
||||
echo "Federated nodes can now install via:"
|
||||
echo " podman pull $FULL_TAG"
|
||||
echo ""
|
||||
echo "Update marketplace dockerImage to: $FULL_TAG"
|
||||
@@ -22,6 +22,9 @@ app:
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: lightning-stack
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ app:
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: lnd
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ app:
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: mempool
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ app:
|
||||
security:
|
||||
capabilities: [NET_ADMIN, SYS_ADMIN] # Required for LoRa radio access
|
||||
readonly_root: false # Needs write access for device management
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: host # Requires host network for radio access
|
||||
apparmor_profile: meshtastic
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ app:
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: morphos-server
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ app:
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: nostr-relay
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ app:
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false # Ollama needs write access for models
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: ollama
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ app:
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false # OnlyOffice needs write access
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: onlyoffice
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ app:
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: penpot
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ app:
|
||||
security:
|
||||
capabilities: [NET_ADMIN, NET_RAW] # Required for network management
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: host # Requires host network for routing
|
||||
apparmor_profile: router
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ app:
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: searxng
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ app:
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: nostr-relay
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ app:
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: web5-dwn
|
||||
|
||||
|
||||
22
core/.cargo/config.toml
Normal file
22
core/.cargo/config.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
# Cargo configuration for Archipelago cross-compilation
|
||||
#
|
||||
# Native builds (x86_64 on x86_64) work automatically.
|
||||
# ARM64 cross-compilation requires the aarch64-unknown-linux-gnu toolchain.
|
||||
#
|
||||
# Install the target:
|
||||
# rustup target add aarch64-unknown-linux-gnu
|
||||
#
|
||||
# Install the cross-linker (Debian/Ubuntu):
|
||||
# sudo apt install gcc-aarch64-linux-gnu
|
||||
#
|
||||
# Build for ARM64:
|
||||
# cargo build --release --target aarch64-unknown-linux-gnu
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
|
||||
# OpenSSL cross-compilation environment (set before building)
|
||||
# These are automatically set by the build scripts but documented here:
|
||||
# OPENSSL_DIR=/usr/aarch64-linux-gnu
|
||||
# PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig
|
||||
# PKG_CONFIG_ALLOW_CROSS=1
|
||||
632
core/Cargo.lock
generated
632
core/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "0.1.0"
|
||||
version = "1.2.0-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
@@ -42,11 +42,13 @@ archipelago-parmanode = { path = "../parmanode" }
|
||||
# Authentication
|
||||
bcrypt = "0.15"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
regex = "1.10"
|
||||
|
||||
# Node identity (Ed25519)
|
||||
# Node identity (Ed25519 + X25519 key agreement)
|
||||
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
|
||||
curve25519-dalek = "4"
|
||||
rand = "0.8"
|
||||
hex = "0.4"
|
||||
bs58 = "0.5"
|
||||
@@ -57,22 +59,46 @@ toml = "0.8"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging)
|
||||
reqwest = { version = "0.11", features = ["json", "socks"] }
|
||||
# Uses rustls-tls for cross-compilation (no OpenSSL dependency)
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls"] }
|
||||
|
||||
# Nostr (node discovery)
|
||||
nostr-sdk = "0.44"
|
||||
# Nostr (node discovery + NIP-44 encrypted peer handshake)
|
||||
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
|
||||
|
||||
# Backup encryption (DID identity export) + TOTP 2FA encryption
|
||||
argon2 = "0.5"
|
||||
chacha20poly1305 = "0.10"
|
||||
base64 = "0.21"
|
||||
|
||||
# Full system backup (tar archive + gzip compression)
|
||||
tar = "0.4"
|
||||
flate2 = "1.0"
|
||||
|
||||
# TOTP 2FA
|
||||
totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] }
|
||||
qrcode = "0.14"
|
||||
data-encoding = "2.6"
|
||||
zeroize = { version = "1.7", features = ["derive"] }
|
||||
|
||||
# Mainline DHT (did:dht — BitTorrent DHT for decentralized identity)
|
||||
mainline = "2"
|
||||
zbase32 = "0.1"
|
||||
bytes = "1"
|
||||
|
||||
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
|
||||
serial2-tokio = "0.1"
|
||||
|
||||
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
|
||||
hkdf = "0.12"
|
||||
|
||||
# Transport abstraction (Phase 2: mesh as federation transport)
|
||||
ciborium = "0.2.2"
|
||||
reed-solomon-erasure = "6.0"
|
||||
mdns-sd = "0.18"
|
||||
|
||||
# Systemd watchdog notification
|
||||
sd-notify = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3.10"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::content_server;
|
||||
use crate::electrs_status;
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::network::dwn_store::DwnStore;
|
||||
use crate::node_message as node_msg;
|
||||
use crate::config::Config;
|
||||
use crate::session::{self, SessionStore};
|
||||
@@ -12,30 +14,48 @@ use hyper_ws_listener::WsStream;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use std::time::Instant;
|
||||
use tracing::{debug, info};
|
||||
|
||||
pub struct ApiHandler {
|
||||
config: Config,
|
||||
rpc_handler: Arc<RpcHandler>,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
session_store: SessionStore,
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
pub async fn new(config: Config, state_manager: Arc<StateManager>) -> Result<Self> {
|
||||
pub async fn new(
|
||||
config: Config,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
) -> Result<Self> {
|
||||
let session_store = SessionStore::new();
|
||||
let rpc_handler = Arc::new(
|
||||
RpcHandler::new(config.clone(), state_manager.clone(), session_store.clone()).await?,
|
||||
RpcHandler::new(
|
||||
config.clone(),
|
||||
state_manager.clone(),
|
||||
metrics_store.clone(),
|
||||
session_store.clone(),
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
rpc_handler,
|
||||
state_manager,
|
||||
metrics_store,
|
||||
session_store,
|
||||
})
|
||||
}
|
||||
|
||||
/// Access the RPC handler (for service initialization after construction).
|
||||
pub fn rpc_handler(&self) -> &Arc<RpcHandler> {
|
||||
&self.rpc_handler
|
||||
}
|
||||
|
||||
/// Check if the request has a valid session cookie.
|
||||
async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool {
|
||||
match session::extract_session_cookie(headers) {
|
||||
@@ -103,9 +123,10 @@ impl ApiHandler {
|
||||
// WebSocket upgrade — validate session before upgrading
|
||||
if method == Method::GET && path == "/ws/db" {
|
||||
if !self.is_authenticated(req.headers()).await {
|
||||
tracing::warn!("401 WebSocket /ws/db — session invalid or missing");
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
return Self::handle_websocket(req, self.state_manager.clone()).await;
|
||||
return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await;
|
||||
}
|
||||
|
||||
// Convert body to bytes for non-WS routes
|
||||
@@ -145,6 +166,11 @@ impl ApiHandler {
|
||||
// Electrs status — unauthenticated (read-only sync status)
|
||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||
|
||||
// LND connect info — unauthenticated (read-only, localhost only)
|
||||
(Method::GET, "/lnd-connect-info") => {
|
||||
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
|
||||
}
|
||||
|
||||
// Container logs — requires session
|
||||
(Method::GET, path) if path.starts_with("/api/container/logs") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
@@ -163,6 +189,16 @@ impl ApiHandler {
|
||||
Self::handle_lnd_proxy(path, &origin).await
|
||||
}
|
||||
|
||||
// DWN health — unauthenticated
|
||||
(Method::GET, "/dwn/health") => {
|
||||
Self::handle_dwn_health(&self.config).await
|
||||
}
|
||||
|
||||
// DWN message processing — peers access over Tor for sync (no session auth)
|
||||
(Method::POST, "/dwn") => {
|
||||
Self::handle_dwn_message(body_bytes, &self.config).await
|
||||
}
|
||||
|
||||
_ => Ok(Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(hyper::Body::from("Not Found"))
|
||||
@@ -281,6 +317,28 @@ impl ApiHandler {
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
async fn handle_lnd_connect_info(
|
||||
rpc: std::sync::Arc<super::rpc::RpcHandler>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
match rpc.handle_lnd_connect_info().await {
|
||||
Ok(val) => {
|
||||
let body = serde_json::to_vec(&val).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap())
|
||||
}
|
||||
Err(e) => Ok(Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(
|
||||
serde_json::json!({"error": e.to_string()}).to_string(),
|
||||
))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_lnd_proxy(path: &str, cors_origin: &str) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
let url = format!("http://127.0.0.1:8080{}", suffix);
|
||||
@@ -320,10 +378,11 @@ impl ApiHandler {
|
||||
async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
|
||||
match content_server::load_catalog(&config.data_dir).await {
|
||||
Ok(catalog) => {
|
||||
// Only expose public metadata, not file paths
|
||||
// Only expose public metadata for available items
|
||||
let items: Vec<serde_json::Value> = catalog
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody))
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"id": i.id,
|
||||
@@ -374,6 +433,12 @@ impl ApiHandler {
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Extract federation peer DID from X-Federation-DID header
|
||||
let peer_did = headers
|
||||
.get("x-federation-did")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Parse Range header for streaming support
|
||||
let range = headers
|
||||
.get("range")
|
||||
@@ -384,6 +449,7 @@ impl ApiHandler {
|
||||
&config.data_dir,
|
||||
content_id,
|
||||
payment_token.as_deref(),
|
||||
peer_did.as_deref(),
|
||||
range,
|
||||
)
|
||||
.await
|
||||
@@ -427,6 +493,15 @@ impl ApiHandler {
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::ServeResult::Forbidden) => {
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(
|
||||
r#"{"error":"Access denied — federation peer required"}"#,
|
||||
))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::ServeResult::NotFound) | Err(_) => {
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
@@ -439,6 +514,7 @@ impl ApiHandler {
|
||||
async fn handle_websocket(
|
||||
req: Request<hyper::Body>,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||
@@ -456,6 +532,7 @@ impl ApiHandler {
|
||||
return;
|
||||
}
|
||||
};
|
||||
metrics_store.increment_ws();
|
||||
info!("WebSocket /ws/db connected");
|
||||
|
||||
let (mut tx, mut rx) = ws_stream.split();
|
||||
@@ -472,10 +549,18 @@ impl ApiHandler {
|
||||
let mut state_rx = state_manager.subscribe();
|
||||
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||
tokio::pin!(ping_interval);
|
||||
let mut last_client_activity = Instant::now();
|
||||
const INACTIVITY_TIMEOUT_SECS: u64 = 300; // 5 minutes
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ping_interval.tick() => {
|
||||
// Check inactivity timeout
|
||||
if last_client_activity.elapsed().as_secs() >= INACTIVITY_TIMEOUT_SECS {
|
||||
info!("WebSocket client inactive for {}s, closing", INACTIVITY_TIMEOUT_SECS);
|
||||
let _ = tx.send(Message::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
if tx.send(Message::Ping(vec![])).await.is_err() {
|
||||
debug!("Failed to send ping, connection likely closed");
|
||||
break;
|
||||
@@ -505,12 +590,23 @@ impl ApiHandler {
|
||||
match msg {
|
||||
Some(Ok(Message::Close(_))) => break,
|
||||
Some(Ok(Message::Pong(_))) => {
|
||||
last_client_activity = Instant::now();
|
||||
debug!("Received pong");
|
||||
}
|
||||
Some(Ok(Message::Ping(data))) => {
|
||||
last_client_activity = Instant::now();
|
||||
let _ = tx.send(Message::Pong(data)).await;
|
||||
}
|
||||
Some(Ok(_)) => {}
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
last_client_activity = Instant::now();
|
||||
// Handle JSON ping from frontend
|
||||
if text.contains("\"type\":\"ping\"") || text.contains("\"type\": \"ping\"") {
|
||||
let _ = tx.send(Message::Text(r#"{"type":"pong"}"#.to_string())).await;
|
||||
}
|
||||
}
|
||||
Some(Ok(_)) => {
|
||||
last_client_activity = Instant::now();
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
debug!("WebSocket stream error: {}", e);
|
||||
break;
|
||||
@@ -520,6 +616,7 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
metrics_store.decrement_ws();
|
||||
info!("WebSocket /ws/db disconnected");
|
||||
});
|
||||
}
|
||||
@@ -556,3 +653,194 @@ fn sanitize_html(s: &str) -> String {
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
/// DWN health endpoint — returns store stats.
|
||||
async fn handle_dwn_health(config: &Config) -> Result<Response<hyper::Body>> {
|
||||
match DwnStore::new(&config.data_dir).await {
|
||||
Ok(store) => {
|
||||
let stats = store.stats().await.unwrap_or(crate::network::dwn_store::StoreStats {
|
||||
message_count: 0,
|
||||
protocol_count: 0,
|
||||
total_bytes: 0,
|
||||
});
|
||||
let body = serde_json::json!({
|
||||
"status": "ok",
|
||||
"message_count": stats.message_count,
|
||||
"protocol_count": stats.protocol_count,
|
||||
"total_bytes": stats.total_bytes,
|
||||
});
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body.to_string()))
|
||||
.unwrap())
|
||||
}
|
||||
Err(_) => Ok(Response::builder()
|
||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(r#"{"status":"unavailable"}"#))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
/// DWN message processing endpoint — handles RecordsWrite, RecordsQuery, RecordsRead, RecordsDelete.
|
||||
/// Supports batch processing: all messages in the array are processed.
|
||||
async fn handle_dwn_message(
|
||||
body: hyper::body::Bytes,
|
||||
config: &Config,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let request: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let err = serde_json::json!({"error": format!("Invalid JSON: {}", e)});
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(err.to_string()))
|
||||
.unwrap());
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all messages to process
|
||||
let messages: Vec<serde_json::Value> = if request.get("message").is_some() {
|
||||
vec![request["message"].clone()]
|
||||
} else if let Some(msgs) = request["messages"].as_array() {
|
||||
msgs.clone()
|
||||
} else {
|
||||
vec![serde_json::Value::Null]
|
||||
};
|
||||
|
||||
let store = DwnStore::new(&config.data_dir).await?;
|
||||
let mut results = Vec::new();
|
||||
|
||||
for message in &messages {
|
||||
let interface = message["descriptor"]["interface"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
let method = message["descriptor"]["method"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
|
||||
let result = match (interface, method) {
|
||||
("Records", "Write") => {
|
||||
let author = message["author"].as_str().unwrap_or("unknown");
|
||||
let protocol = message["descriptor"]["protocol"].as_str();
|
||||
let schema = message["descriptor"]["schema"].as_str();
|
||||
let data_format = message["descriptor"]["dataFormat"].as_str();
|
||||
let data = message.get("data").cloned();
|
||||
// Deduplicate: check if recordId already exists
|
||||
if let Some(record_id) = message["recordId"].as_str() {
|
||||
if store.read_message(record_id).await.ok().flatten().is_some() {
|
||||
serde_json::json!({"status": {"code": 200, "detail": "Already exists"}})
|
||||
} else {
|
||||
match store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await
|
||||
{
|
||||
Ok(msg) => {
|
||||
serde_json::json!({"status": {"code": 202}, "entry": msg})
|
||||
}
|
||||
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await
|
||||
{
|
||||
Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}),
|
||||
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Query") => {
|
||||
let query = crate::network::dwn_store::MessageQuery {
|
||||
protocol: message["descriptor"]["filter"]["protocol"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
schema: message["descriptor"]["filter"]["schema"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
author: message["descriptor"]["filter"]["author"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
date_from: message["descriptor"]["filter"]["dateFrom"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
date_to: message["descriptor"]["filter"]["dateTo"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
limit: message["descriptor"]["filter"]["limit"]
|
||||
.as_u64()
|
||||
.map(|n| n as usize),
|
||||
};
|
||||
match store.query_messages(&query).await {
|
||||
Ok(messages) => {
|
||||
serde_json::json!({"status": {"code": 200}, "entries": messages})
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Read") => {
|
||||
let record_id = message["descriptor"]["recordId"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
match store.read_message(record_id).await {
|
||||
Ok(Some(msg)) => {
|
||||
serde_json::json!({"status": {"code": 200}, "entry": msg})
|
||||
}
|
||||
Ok(None) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Delete") => {
|
||||
let record_id = message["descriptor"]["recordId"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
match store.delete_message(record_id).await {
|
||||
Ok(true) => serde_json::json!({"status": {"code": 200}}),
|
||||
Ok(false) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
serde_json::json!({"status": {"code": 400, "detail": format!("Unknown method: {}.{}", interface, method)}})
|
||||
}
|
||||
};
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Return single result for single message, array for batch
|
||||
let (response_body, http_status) = if results.len() == 1 {
|
||||
let result = &results[0];
|
||||
let status_code = result["status"]["code"].as_u64().unwrap_or(200);
|
||||
let http_status = match status_code {
|
||||
202 => StatusCode::ACCEPTED,
|
||||
400 => StatusCode::BAD_REQUEST,
|
||||
404 => StatusCode::NOT_FOUND,
|
||||
500 => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
_ => StatusCode::OK,
|
||||
};
|
||||
(result.to_string(), http_status)
|
||||
} else {
|
||||
(
|
||||
serde_json::json!({"replies": results}).to_string(),
|
||||
StatusCode::OK,
|
||||
)
|
||||
};
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(http_status)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(response_body))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
120
core/archipelago/src/api/rpc/analytics.rs
Normal file
120
core/archipelago/src/api/rpc/analytics.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
//! Opt-in anonymous node analytics.
|
||||
//! When enabled, collects aggregate stats (app install counts, uptime, hardware tier).
|
||||
//! No personally identifiable information. No IP addresses. No DIDs.
|
||||
//! Data stays local until explicitly shared via future relay mechanism.
|
||||
|
||||
use super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
const ANALYTICS_FILE: &str = "analytics-config.json";
|
||||
|
||||
impl RpcHandler {
|
||||
/// Check if analytics are enabled.
|
||||
pub(super) async fn handle_analytics_get_status(&self) -> Result<serde_json::Value> {
|
||||
let config_path = self.config.data_dir.join(ANALYTICS_FILE);
|
||||
let enabled = if config_path.exists() {
|
||||
let data = tokio::fs::read_to_string(&config_path).await?;
|
||||
let config: serde_json::Value = serde_json::from_str(&data).unwrap_or_default();
|
||||
config["enabled"].as_bool().unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"enabled": enabled,
|
||||
"description": "Anonymous aggregate statistics. No personal data collected.",
|
||||
}))
|
||||
}
|
||||
|
||||
/// Enable opt-in analytics.
|
||||
pub(super) async fn handle_analytics_enable(&self) -> Result<serde_json::Value> {
|
||||
let config_path = self.config.data_dir.join(ANALYTICS_FILE);
|
||||
let config = serde_json::json!({
|
||||
"enabled": true,
|
||||
"opted_in_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
tokio::fs::write(&config_path, serde_json::to_string_pretty(&config)?).await?;
|
||||
info!("Analytics opted in");
|
||||
Ok(serde_json::json!({ "enabled": true }))
|
||||
}
|
||||
|
||||
/// Disable analytics.
|
||||
pub(super) async fn handle_analytics_disable(&self) -> Result<serde_json::Value> {
|
||||
let config_path = self.config.data_dir.join(ANALYTICS_FILE);
|
||||
let config = serde_json::json!({
|
||||
"enabled": false,
|
||||
"opted_out_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
tokio::fs::write(&config_path, serde_json::to_string_pretty(&config)?).await?;
|
||||
info!("Analytics opted out");
|
||||
Ok(serde_json::json!({ "enabled": false }))
|
||||
}
|
||||
|
||||
/// Get an anonymous analytics snapshot of this node.
|
||||
/// Only returns aggregate data — no DIDs, no IPs, no secrets.
|
||||
pub(super) async fn handle_analytics_get_snapshot(&self) -> Result<serde_json::Value> {
|
||||
// Check if opted in
|
||||
let config_path = self.config.data_dir.join(ANALYTICS_FILE);
|
||||
let enabled = if config_path.exists() {
|
||||
let data = tokio::fs::read_to_string(&config_path).await?;
|
||||
let config: serde_json::Value = serde_json::from_str(&data).unwrap_or_default();
|
||||
config["enabled"].as_bool().unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !enabled {
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Analytics not enabled. Opt in via analytics.enable first.",
|
||||
"enabled": false,
|
||||
}));
|
||||
}
|
||||
|
||||
// Collect anonymous aggregate data
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
|
||||
let app_count = data.package_data.len();
|
||||
let running_count = data.package_data.values()
|
||||
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running))
|
||||
.count();
|
||||
|
||||
// Hardware tier (anonymous)
|
||||
let cpu_cores = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mem_output = tokio::process::Command::new("grep")
|
||||
.args(["MemTotal", "/proc/meminfo"])
|
||||
.output()
|
||||
.await;
|
||||
let total_ram_mb = mem_output.ok()
|
||||
.and_then(|o| {
|
||||
let s = String::from_utf8_lossy(&o.stdout);
|
||||
s.split_whitespace().nth(1)?.parse::<u64>().ok()
|
||||
})
|
||||
.map(|kb| kb / 1024)
|
||||
.unwrap_or(0);
|
||||
|
||||
let hardware_tier = match total_ram_mb {
|
||||
0..=3999 => "minimal",
|
||||
4000..=7999 => "standard",
|
||||
8000..=15999 => "power",
|
||||
_ => "heavy",
|
||||
};
|
||||
|
||||
let version = &data.server_info.version;
|
||||
let federation_peers = data.peer_health.len();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"version": version,
|
||||
"app_count": app_count,
|
||||
"running_count": running_count,
|
||||
"hardware_tier": hardware_tier,
|
||||
"cpu_cores": cpu_cores,
|
||||
"ram_mb": total_ram_mb,
|
||||
"federation_peers": federation_peers,
|
||||
"collected_at": chrono::Utc::now().to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ impl RpcHandler {
|
||||
pub(super) async fn handle_auth_change_password(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
session_token: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let current_password = params
|
||||
@@ -57,7 +58,12 @@ impl RpcHandler {
|
||||
.change_password(current_password, new_password, also_change_ssh)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
// Session rotation: invalidate all other sessions, rotate the caller's session
|
||||
if let Some(token) = session_token {
|
||||
self.session_store.invalidate_all_except(token).await;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "success": true, "session_rotated": true }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||
|
||||
325
core/archipelago/src/api/rpc/backup_rpc.rs
Normal file
325
core/archipelago/src/api/rpc/backup_rpc.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
use super::RpcHandler;
|
||||
use crate::backup::full;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Create a full encrypted backup. Params: { passphrase, description? }
|
||||
pub(super) async fn handle_backup_create(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let passphrase = params["passphrase"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||
let description = params["description"].as_str();
|
||||
|
||||
let meta = full::create_full_backup(&self.config.data_dir, passphrase, description).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": meta.id,
|
||||
"created_at": meta.created_at,
|
||||
"size_bytes": meta.size_bytes,
|
||||
"encrypted": meta.encrypted,
|
||||
"description": meta.description,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List available backups.
|
||||
pub(super) async fn handle_backup_list(&self) -> Result<serde_json::Value> {
|
||||
let backups = full::list_backups(&self.config.data_dir).await?;
|
||||
let list: Vec<serde_json::Value> = backups
|
||||
.iter()
|
||||
.map(|b| {
|
||||
serde_json::json!({
|
||||
"id": b.id,
|
||||
"created_at": b.created_at,
|
||||
"size_bytes": b.size_bytes,
|
||||
"encrypted": b.encrypted,
|
||||
"description": b.description,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!({ "backups": list }))
|
||||
}
|
||||
|
||||
/// Verify a backup's integrity. Params: { id, passphrase }
|
||||
pub(super) async fn handle_backup_verify(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
||||
let passphrase = params["passphrase"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||
|
||||
let result = full::verify_backup(&self.config.data_dir, id, passphrase).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"valid": result.valid,
|
||||
"id": result.id,
|
||||
"created_at": result.created_at,
|
||||
"size_bytes": result.size_bytes,
|
||||
"error": result.error,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Restore from a backup. Params: { id, passphrase }
|
||||
pub(super) async fn handle_backup_restore(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
||||
let passphrase = params["passphrase"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||
|
||||
full::restore_full_backup(&self.config.data_dir, id, passphrase).await?;
|
||||
|
||||
Ok(serde_json::json!({ "restored": true, "id": id }))
|
||||
}
|
||||
|
||||
/// Delete a backup. Params: { id }
|
||||
pub(super) async fn handle_backup_delete(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
||||
|
||||
// Validate backup ID to prevent path traversal
|
||||
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
let bak_path = full::backup_file_path(&self.config.data_dir, id);
|
||||
let meta_path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("backups")
|
||||
.join(format!("{}.meta.json", id));
|
||||
|
||||
let mut deleted = false;
|
||||
if bak_path.exists() {
|
||||
tokio::fs::remove_file(&bak_path).await?;
|
||||
deleted = true;
|
||||
}
|
||||
if meta_path.exists() {
|
||||
tokio::fs::remove_file(&meta_path).await?;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
|
||||
}
|
||||
|
||||
/// List removable USB drives.
|
||||
pub(super) async fn handle_backup_list_drives(&self) -> Result<serde_json::Value> {
|
||||
let drives = full::list_usb_drives().await?;
|
||||
let list: Vec<serde_json::Value> = drives
|
||||
.iter()
|
||||
.map(|d| {
|
||||
serde_json::json!({
|
||||
"device": d.device,
|
||||
"mount_point": d.mount_point,
|
||||
"label": d.label,
|
||||
"size_bytes": d.size_bytes,
|
||||
"removable": d.removable,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!({ "drives": list }))
|
||||
}
|
||||
|
||||
/// Copy a backup to a mounted USB drive. Params: { id, mount_point }
|
||||
pub(super) async fn handle_backup_to_usb(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
||||
let mount_point = params["mount_point"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'mount_point' parameter"))?;
|
||||
|
||||
let dest = full::backup_to_usb(&self.config.data_dir, id, mount_point).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"copied": true,
|
||||
"id": id,
|
||||
"destination": dest.to_string_lossy(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Upload a backup to S3-compatible storage.
|
||||
/// Params: { id, endpoint, bucket, access_key, secret_key, region? }
|
||||
pub(super) async fn handle_backup_upload_s3(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
||||
let endpoint = params["endpoint"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'endpoint' parameter"))?;
|
||||
let bucket = params["bucket"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'bucket' parameter"))?;
|
||||
let access_key = params["access_key"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'access_key' parameter"))?;
|
||||
let secret_key = params["secret_key"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'secret_key' parameter"))?;
|
||||
let _region = params["region"].as_str().unwrap_or("us-east-1");
|
||||
|
||||
// Validate backup ID
|
||||
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
let bak_path = full::backup_file_path(&self.config.data_dir, id);
|
||||
if !bak_path.exists() {
|
||||
anyhow::bail!("Backup not found: {}", id);
|
||||
}
|
||||
|
||||
let file_bytes = tokio::fs::read(&bak_path)
|
||||
.await
|
||||
.context("Failed to read backup file")?;
|
||||
let key = format!("archipelago-backups/{}.tar.gz.enc", id);
|
||||
let size = file_bytes.len();
|
||||
|
||||
// Upload via HTTP PUT to S3-compatible endpoint
|
||||
let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()?;
|
||||
|
||||
// Simple S3 PUT (works with MinIO, Backblaze B2 S3-compatible, Wasabi)
|
||||
// For full AWS S3, proper SigV4 signing would be needed
|
||||
let response = client
|
||||
.put(&url)
|
||||
.basic_auth(access_key, Some(secret_key))
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.body(file_bytes)
|
||||
.send()
|
||||
.await
|
||||
.context("S3 upload failed")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("S3 upload failed ({}): {}", status, &body[..200.min(body.len())]);
|
||||
}
|
||||
|
||||
info!(id = %id, bucket = %bucket, size = %size, "Backup uploaded to S3");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"uploaded": true,
|
||||
"id": id,
|
||||
"bucket": bucket,
|
||||
"key": key,
|
||||
"size_bytes": size,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Download a backup from S3-compatible storage.
|
||||
/// Params: { id, endpoint, bucket, access_key, secret_key, region? }
|
||||
pub(super) async fn handle_backup_download_s3(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
||||
let endpoint = params["endpoint"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'endpoint' parameter"))?;
|
||||
let bucket = params["bucket"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'bucket' parameter"))?;
|
||||
let access_key = params["access_key"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'access_key' parameter"))?;
|
||||
let secret_key = params["secret_key"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'secret_key' parameter"))?;
|
||||
|
||||
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
let key = format!("archipelago-backups/{}.tar.gz.enc", id);
|
||||
let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.basic_auth(access_key, Some(secret_key))
|
||||
.send()
|
||||
.await
|
||||
.context("S3 download failed")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
anyhow::bail!("S3 download failed ({})", status);
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await.context("Failed to read S3 response")?;
|
||||
let size = bytes.len();
|
||||
|
||||
// Save to backups directory
|
||||
let bak_dir = self.config.data_dir.join("backups");
|
||||
tokio::fs::create_dir_all(&bak_dir).await?;
|
||||
let bak_path = full::backup_file_path(&self.config.data_dir, id);
|
||||
tokio::fs::write(&bak_path, &bytes).await.context("Failed to write backup file")?;
|
||||
|
||||
info!(id = %id, bucket = %bucket, size = %size, "Backup downloaded from S3");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"downloaded": true,
|
||||
"id": id,
|
||||
"size_bytes": size,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Restore identity from an encrypted DID backup JSON.
|
||||
/// Params: { backup: { version, blob, ... }, passphrase }
|
||||
pub(super) async fn handle_backup_restore_identity(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let backup = params
|
||||
.get("backup")
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'backup' parameter"))?;
|
||||
let passphrase = params
|
||||
.get("passphrase")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let (did, pubkey) = crate::backup::restore_encrypted_backup(
|
||||
&identity_dir,
|
||||
backup,
|
||||
passphrase,
|
||||
)
|
||||
.await
|
||||
.context("Identity restore failed")?;
|
||||
|
||||
info!(did = %did, "Identity restored from backup");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"did": did,
|
||||
"pubkey": pubkey,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ impl RpcHandler {
|
||||
method: &str,
|
||||
params: &[serde_json::Value],
|
||||
) -> Result<T> {
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "archy",
|
||||
@@ -86,7 +87,7 @@ impl RpcHandler {
|
||||
|
||||
let resp = client
|
||||
.post("http://127.0.0.1:8332/")
|
||||
.basic_auth("archipelago", Some("archipelago123"))
|
||||
.basic_auth(&rpc_user, Some(&rpc_pass))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::RpcHandler;
|
||||
use super::package::validate_app_id;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -17,24 +18,29 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing manifest_path"))?;
|
||||
|
||||
// Validate manifest path: reject path traversal and paths outside apps/
|
||||
if manifest_path.contains("..") {
|
||||
// Validate manifest path: reject traversal, resolve to canonical path
|
||||
if manifest_path.contains("..") || manifest_path.contains('\0') {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid manifest_path: path traversal not allowed"
|
||||
));
|
||||
}
|
||||
let path = std::path::Path::new(manifest_path);
|
||||
if path.is_absolute() {
|
||||
let apps_dir = self.config.data_dir.join("apps");
|
||||
if !path.starts_with(&apps_dir) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid manifest_path: must be under the apps directory"
|
||||
));
|
||||
}
|
||||
let apps_dir = self.config.data_dir.join("apps");
|
||||
let resolved = if std::path::Path::new(manifest_path).is_absolute() {
|
||||
std::path::PathBuf::from(manifest_path)
|
||||
} else {
|
||||
apps_dir.join(manifest_path)
|
||||
};
|
||||
let canonical = resolved
|
||||
.canonicalize()
|
||||
.context("Invalid manifest_path: file not found")?;
|
||||
if !canonical.starts_with(&apps_dir) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid manifest_path: must be under the apps directory"
|
||||
));
|
||||
}
|
||||
|
||||
// Load manifest
|
||||
let manifest_content = tokio::fs::read_to_string(manifest_path)
|
||||
let manifest_content = tokio::fs::read_to_string(&canonical)
|
||||
.await
|
||||
.context("Failed to read manifest file")?;
|
||||
let manifest: archipelago_container::AppManifest = serde_yaml::from_str(&manifest_content)
|
||||
@@ -62,6 +68,7 @@ impl RpcHandler {
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
orchestrator
|
||||
.start_container(app_id)
|
||||
@@ -85,6 +92,7 @@ impl RpcHandler {
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
orchestrator
|
||||
.stop_container(app_id)
|
||||
@@ -108,6 +116,7 @@ impl RpcHandler {
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
let preserve_data = params
|
||||
.get("preserve_data")
|
||||
.and_then(|v| v.as_bool())
|
||||
@@ -131,9 +140,9 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: list containers directly via sudo podman (for bundled apps)
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "-a", "--format", "json"])
|
||||
// Fallback: list containers directly via podman (for bundled apps)
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "json"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers via podman")?;
|
||||
@@ -167,23 +176,58 @@ impl RpcHandler {
|
||||
|
||||
let name = c.get("Names").and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
// Determine lan_address based on container name
|
||||
// Map container name to its UI port (lan_address)
|
||||
let lan_address = match name {
|
||||
"bitcoin-knots" => Some("http://localhost:8334"),
|
||||
"lnd" => Some("http://localhost:8081"),
|
||||
"bitcoin-knots" | "bitcoin-ui" => Some("http://localhost:8334"),
|
||||
"lnd" | "archy-lnd-ui" => Some("http://localhost:8081"),
|
||||
"tailscale" => Some("http://localhost:8240"),
|
||||
"homeassistant" => Some("http://localhost:8123"),
|
||||
"archy-mempool-web" | "mempool" => Some("http://localhost:4080"),
|
||||
"btcpay-server" => Some("http://localhost:23000"),
|
||||
"grafana" => Some("http://localhost:3000"),
|
||||
"searxng" => Some("http://localhost:8888"),
|
||||
"ollama" => Some("http://localhost:11434"),
|
||||
"onlyoffice" => Some("http://localhost:9980"),
|
||||
"penpot" => Some("http://localhost:9001"),
|
||||
"nextcloud" => Some("http://localhost:8085"),
|
||||
"vaultwarden" => Some("http://localhost:8082"),
|
||||
"jellyfin" => Some("http://localhost:8096"),
|
||||
"photoprism" => Some("http://localhost:2342"),
|
||||
"immich_server" | "immich" => Some("http://localhost:2283"),
|
||||
"filebrowser" => Some("http://localhost:8083"),
|
||||
"nginx-proxy-manager" => Some("http://localhost:81"),
|
||||
"portainer" => Some("http://localhost:9000"),
|
||||
"uptime-kuma" => Some("http://localhost:3001"),
|
||||
"fedimint" => Some("http://localhost:8175"),
|
||||
"fedimint-gateway" => Some("http://localhost:8176"),
|
||||
"nostr-rs-relay" => Some("http://localhost:18081"),
|
||||
"indeedhub" => Some("http://localhost:7777"),
|
||||
"dwn" => Some("http://localhost:3100"),
|
||||
"endurain" => Some("http://localhost:8080"),
|
||||
"electrs" | "archy-electrs-ui" => Some("http://localhost:50002"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Parse ports from podman JSON (field is "host_port" in snake_case)
|
||||
let ports: Vec<String> = c.get("Ports")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter().filter_map(|p| {
|
||||
let host = p.get("host_port").and_then(|v| v.as_u64())?;
|
||||
let container = p.get("container_port").and_then(|v| v.as_u64())?;
|
||||
let proto = p.get("protocol").and_then(|v| v.as_str()).unwrap_or("tcp");
|
||||
Some(format!("0.0.0.0:{}->{}/{}", host, container, proto))
|
||||
}).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
serde_json::json!({
|
||||
"id": c.get("Id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"name": name,
|
||||
"state": mapped_state,
|
||||
"image": c.get("Image").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"created": c.get("Created").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"ports": c.get("Ports").and_then(|v| v.as_array()).map(|a|
|
||||
a.iter().filter_map(|p| p.get("hostPort").and_then(|v| v.as_u64()).map(|p| p.to_string())).collect::<Vec<_>>()
|
||||
).unwrap_or_default(),
|
||||
"ports": ports,
|
||||
"lan_address": lan_address,
|
||||
})
|
||||
})
|
||||
@@ -206,6 +250,7 @@ impl RpcHandler {
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
let status = orchestrator
|
||||
.get_container_status(app_id)
|
||||
@@ -229,6 +274,7 @@ impl RpcHandler {
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
let lines = params
|
||||
.get("lines")
|
||||
.and_then(|v| v.as_u64())
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use super::RpcHandler;
|
||||
use crate::content_server::{self, AccessControl, Availability, ContentItem};
|
||||
use crate::network::dwn_store::DwnStore;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::debug;
|
||||
|
||||
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
||||
|
||||
impl RpcHandler {
|
||||
/// List content I'm sharing.
|
||||
pub(super) async fn handle_content_list_mine(
|
||||
@@ -31,7 +34,7 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let item = ContentItem {
|
||||
let mut item = ContentItem {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
filename: filename.to_string(),
|
||||
mime_type: mime_type.to_string(),
|
||||
@@ -42,7 +45,43 @@ impl RpcHandler {
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
// Resolve actual file size from disk
|
||||
let file_path = content_server::content_file_path(&self.config.data_dir, &item);
|
||||
if let Ok(metadata) = std::fs::metadata(&file_path) {
|
||||
item.size_bytes = metadata.len();
|
||||
}
|
||||
|
||||
content_server::add_item(&self.config.data_dir, item.clone()).await?;
|
||||
|
||||
// Also store as DWN message for interoperable file catalog
|
||||
if let Ok(store) = DwnStore::new(&self.config.data_dir).await {
|
||||
let did = crate::identity::did_key_from_pubkey_hex(
|
||||
&self.state_manager.get_snapshot().await.0.server_info.pubkey,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let dwn_data = serde_json::json!({
|
||||
"id": item.id,
|
||||
"title": item.filename,
|
||||
"description": item.description,
|
||||
"content_type": item.mime_type,
|
||||
"size_bytes": item.size_bytes,
|
||||
"access": format!("{:?}", item.access).to_lowercase(),
|
||||
"created_at": item.added_at,
|
||||
});
|
||||
if let Err(e) = store
|
||||
.write_message(
|
||||
&did,
|
||||
Some(FILE_CATALOG_PROTOCOL),
|
||||
Some("https://archipelago.dev/schemas/file-entry/v1"),
|
||||
Some("application/json"),
|
||||
Some(dwn_data),
|
||||
)
|
||||
.await
|
||||
{
|
||||
debug!("DWN file catalog write (non-fatal): {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "item": item }))
|
||||
}
|
||||
|
||||
@@ -133,6 +172,71 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "updated": true }))
|
||||
}
|
||||
|
||||
/// Download content from a peer over Tor, returning base64-encoded data.
|
||||
pub(super) async fn handle_content_download_peer(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||
let content_id = params
|
||||
.get("content_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||
|
||||
if !onion.ends_with(".onion") || onion.len() < 10 {
|
||||
return Err(anyhow::anyhow!("Invalid onion address"));
|
||||
}
|
||||
|
||||
let socks_proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
|
||||
.context("Failed to create SOCKS proxy")?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(socks_proxy)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.context("Failed to build Tor HTTP client")?;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
|
||||
let url = format!("http://{}/content/{}", onion, content_id);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("X-Federation-DID", &local_did)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to peer over Tor")?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||
return Ok(serde_json::json!({
|
||||
"error": "payment_required",
|
||||
"price_sats": body.get("price_sats").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
}));
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Browse a peer's content catalog over Tor.
|
||||
pub(super) async fn handle_content_browse_peer(
|
||||
&self,
|
||||
|
||||
@@ -28,9 +28,23 @@ impl RpcHandler {
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
let expires_at = params.get("expires_at").and_then(|v| v.as_str());
|
||||
|
||||
let prefer_dht = params
|
||||
.get("prefer_dht_did")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let issuer_record = manager.get(issuer_id).await?;
|
||||
let issuer_did = issuer_record.did.clone();
|
||||
// Use did:dht if available and preferred, otherwise did:key
|
||||
let issuer_did = if prefer_dht {
|
||||
issuer_record
|
||||
.dht_did
|
||||
.as_deref()
|
||||
.unwrap_or(&issuer_record.did)
|
||||
.to_string()
|
||||
} else {
|
||||
issuer_record.did.clone()
|
||||
};
|
||||
|
||||
// Capture identity_id for the signing closure
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
@@ -57,13 +71,15 @@ impl RpcHandler {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = if credentials::is_revoked(&vc) { "revoked" } else { "active" };
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": vc.id,
|
||||
"issuer": vc.issuer,
|
||||
"subject": vc.subject,
|
||||
"subject": vc.credential_subject.id,
|
||||
"type": vc.credential_type,
|
||||
"issued_at": vc.issued_at,
|
||||
"status": vc.status,
|
||||
"issued_at": vc.issuance_date,
|
||||
"status": status,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -97,10 +113,12 @@ impl RpcHandler {
|
||||
})
|
||||
})?;
|
||||
|
||||
let status = if credentials::is_revoked(vc) { "revoked" } else { "active" };
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": vc.id,
|
||||
"valid": valid,
|
||||
"status": vc.status,
|
||||
"status": status,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -118,15 +136,17 @@ impl RpcHandler {
|
||||
let items: Vec<serde_json::Value> = creds
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let status = if credentials::is_revoked(&c) { "revoked" } else { "active" };
|
||||
serde_json::json!({
|
||||
"@context": c.context,
|
||||
"id": c.id,
|
||||
"issuer": c.issuer,
|
||||
"subject": c.subject,
|
||||
"type": c.credential_type,
|
||||
"claims": c.claims,
|
||||
"issued_at": c.issued_at,
|
||||
"expires_at": c.expires_at,
|
||||
"status": c.status,
|
||||
"issuer": c.issuer,
|
||||
"credentialSubject": c.credential_subject,
|
||||
"issuanceDate": c.issuance_date,
|
||||
"expirationDate": c.expiration_date,
|
||||
"proof": c.proof,
|
||||
"status": status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -147,4 +167,86 @@ impl RpcHandler {
|
||||
credentials::revoke_credential(&self.config.data_dir, id).await?;
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Create a Verifiable Presentation bundling selected credentials.
|
||||
pub(super) async fn handle_identity_create_presentation(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let holder_id = params
|
||||
.get("holder_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing holder_id"))?;
|
||||
let credential_ids: Vec<&str> = params
|
||||
.get("credential_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing credential_ids array"))?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect();
|
||||
|
||||
if credential_ids.is_empty() {
|
||||
return Err(anyhow::anyhow!("credential_ids must not be empty"));
|
||||
}
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let holder_record = manager.get(holder_id).await?;
|
||||
let holder_did = holder_record.did.clone();
|
||||
|
||||
let store = credentials::load_credentials(&self.config.data_dir).await?;
|
||||
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let sign_id = holder_id.to_string();
|
||||
|
||||
let vp = credentials::create_presentation(
|
||||
&holder_did,
|
||||
&credential_ids,
|
||||
&store.credentials,
|
||||
|bytes| {
|
||||
let hex_msg = hex::encode(bytes);
|
||||
tokio::task::block_in_place(|| {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
let mgr = IdentityManager::new(&data_dir).await?;
|
||||
mgr.sign(&sign_id, hex_msg.as_bytes()).await
|
||||
})
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(serde_json::to_value(&vp)?)
|
||||
}
|
||||
|
||||
/// Verify a Verifiable Presentation: check holder proof and all embedded credentials.
|
||||
pub(super) async fn handle_identity_verify_presentation(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let presentation = params
|
||||
.get("presentation")
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing presentation"))?;
|
||||
|
||||
let vp: credentials::VerifiablePresentation =
|
||||
serde_json::from_value(presentation.clone())?;
|
||||
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let result = credentials::verify_presentation(&vp, |did, bytes, signature| {
|
||||
let hex_msg = hex::encode(bytes);
|
||||
tokio::task::block_in_place(|| {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
let mgr = IdentityManager::new(&data_dir).await?;
|
||||
mgr.verify(did, hex_msg.as_bytes(), signature).await
|
||||
})
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"valid": result.valid,
|
||||
"holder_valid": result.holder_valid,
|
||||
"credentials": result.credentials,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::RpcHandler;
|
||||
use crate::federation;
|
||||
use crate::network::dwn_store::{DwnStore, MessageQuery, ProtocolDefinition};
|
||||
use crate::network::dwn_sync;
|
||||
use crate::peers;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -12,34 +13,157 @@ impl RpcHandler {
|
||||
version: String::new(),
|
||||
});
|
||||
|
||||
let store = DwnStore::new(&self.config.data_dir).await?;
|
||||
let stats = store.stats().await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"running": server_status.running,
|
||||
"version": server_status.version,
|
||||
"sync_status": sync_state.status,
|
||||
"last_sync": sync_state.last_sync,
|
||||
"messages_synced": sync_state.messages_synced,
|
||||
"storage_bytes": sync_state.storage_bytes,
|
||||
"storage_bytes": stats.total_bytes,
|
||||
"message_count": stats.message_count,
|
||||
"protocol_count": stats.protocol_count,
|
||||
"registered_protocols": sync_state.registered_protocols,
|
||||
"peer_sync_targets": sync_state.peer_sync_targets,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Trigger DWN sync with connected peers.
|
||||
/// Spawns sync as a background task and returns immediately.
|
||||
pub(super) async fn handle_dwn_sync(&self) -> Result<serde_json::Value> {
|
||||
// Get list of connected peers' onion addresses
|
||||
let peer_list = peers::load_peers(&self.config.data_dir).await?;
|
||||
let onions: Vec<String> = peer_list
|
||||
// Check if already syncing
|
||||
let current_state = dwn_sync::load_sync_state(&self.config.data_dir).await?;
|
||||
if matches!(current_state.status, dwn_sync::SyncStatus::Syncing) {
|
||||
return Ok(serde_json::json!({
|
||||
"sync_status": "syncing",
|
||||
"last_sync": current_state.last_sync,
|
||||
"messages_synced": current_state.messages_synced,
|
||||
}));
|
||||
}
|
||||
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
let onions: Vec<String> = nodes
|
||||
.iter()
|
||||
.filter(|p| !p.onion.is_empty())
|
||||
.map(|p| p.onion.clone())
|
||||
.filter(|n| !n.onion.is_empty() && n.trust_level != federation::TrustLevel::Untrusted)
|
||||
.map(|n| n.onion.clone())
|
||||
.collect();
|
||||
|
||||
let state = dwn_sync::sync_with_peers(&self.config.data_dir, &onions).await?;
|
||||
// Spawn sync in background so we don't block the RPC response
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = dwn_sync::sync_with_peers(&data_dir, &onions).await {
|
||||
tracing::warn!(error = %e, "DWN background sync failed");
|
||||
}
|
||||
});
|
||||
|
||||
// Return immediately with "syncing" status
|
||||
Ok(serde_json::json!({
|
||||
"sync_status": state.status,
|
||||
"last_sync": state.last_sync,
|
||||
"messages_synced": state.messages_synced,
|
||||
"sync_status": "syncing",
|
||||
"last_sync": current_state.last_sync,
|
||||
"messages_synced": current_state.messages_synced,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Register a DWN protocol.
|
||||
pub(super) async fn handle_dwn_register_protocol(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let protocol = params["protocol"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'protocol' parameter"))?;
|
||||
let published = params["published"].as_bool().unwrap_or(false);
|
||||
|
||||
let definition = ProtocolDefinition {
|
||||
protocol: protocol.to_string(),
|
||||
published,
|
||||
types: params
|
||||
.get("types")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default(),
|
||||
structure: params
|
||||
.get("structure")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default(),
|
||||
date_registered: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
let store = DwnStore::new(&self.config.data_dir).await?;
|
||||
store.register_protocol(&definition).await?;
|
||||
|
||||
Ok(serde_json::json!({"registered": true, "protocol": protocol}))
|
||||
}
|
||||
|
||||
/// List registered DWN protocols.
|
||||
pub(super) async fn handle_dwn_list_protocols(&self) -> Result<serde_json::Value> {
|
||||
let store = DwnStore::new(&self.config.data_dir).await?;
|
||||
let protocols = store.list_protocols().await?;
|
||||
Ok(serde_json::json!({"protocols": protocols}))
|
||||
}
|
||||
|
||||
/// Remove a DWN protocol.
|
||||
pub(super) async fn handle_dwn_remove_protocol(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let protocol = params["protocol"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'protocol' parameter"))?;
|
||||
|
||||
let store = DwnStore::new(&self.config.data_dir).await?;
|
||||
let removed = store.remove_protocol(protocol).await?;
|
||||
|
||||
Ok(serde_json::json!({"removed": removed, "protocol": protocol}))
|
||||
}
|
||||
|
||||
/// Query DWN messages.
|
||||
pub(super) async fn handle_dwn_query_messages(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let query = MessageQuery {
|
||||
protocol: params["protocol"].as_str().map(|s| s.to_string()),
|
||||
schema: params["schema"].as_str().map(|s| s.to_string()),
|
||||
author: params["author"].as_str().map(|s| s.to_string()),
|
||||
date_from: params["dateFrom"].as_str().map(|s| s.to_string()),
|
||||
date_to: params["dateTo"].as_str().map(|s| s.to_string()),
|
||||
limit: params["limit"].as_u64().map(|n| n as usize),
|
||||
};
|
||||
|
||||
let store = DwnStore::new(&self.config.data_dir).await?;
|
||||
let messages = store.query_messages(&query).await?;
|
||||
|
||||
Ok(serde_json::json!({"messages": messages, "count": messages.len()}))
|
||||
}
|
||||
|
||||
/// Write a DWN message.
|
||||
pub(super) async fn handle_dwn_write_message(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let author = params["author"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'author' parameter"))?;
|
||||
let protocol = params["protocol"].as_str();
|
||||
let schema = params["schema"].as_str();
|
||||
let data_format = params["dataFormat"].as_str();
|
||||
let data = params.get("data").cloned();
|
||||
|
||||
// Limit data size to 10MB to prevent disk exhaustion
|
||||
if let Some(ref d) = data {
|
||||
let data_str = d.to_string();
|
||||
if data_str.len() > 10_485_760 {
|
||||
anyhow::bail!("Message data too large (max 10MB)");
|
||||
}
|
||||
}
|
||||
|
||||
let store = DwnStore::new(&self.config.data_dir).await?;
|
||||
let message = store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({"written": true, "record_id": message.record_id}))
|
||||
}
|
||||
}
|
||||
|
||||
466
core/archipelago/src/api/rpc/federation.rs
Normal file
466
core/archipelago/src/api/rpc/federation.rs
Normal file
@@ -0,0 +1,466 @@
|
||||
use super::RpcHandler;
|
||||
use crate::credentials;
|
||||
use crate::federation::{self, FederatedNode, TrustLevel};
|
||||
use crate::identity;
|
||||
use crate::identity_manager::IdentityManager;
|
||||
use crate::network::dwn_store::DwnStore;
|
||||
use anyhow::Result;
|
||||
use tracing::{debug, info};
|
||||
|
||||
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
|
||||
|
||||
/// Validate a DID parameter: must start with "did:", max 256 chars, no path traversal.
|
||||
fn validate_did(did: &str) -> Result<()> {
|
||||
if did.is_empty() || did.len() > 256 {
|
||||
anyhow::bail!("Invalid DID: must be 1-256 characters");
|
||||
}
|
||||
if !did.starts_with("did:") {
|
||||
anyhow::bail!("Invalid DID: must start with 'did:'");
|
||||
}
|
||||
if did.contains("..") || did.contains('/') || did.contains('\\') || did.contains('\0') {
|
||||
anyhow::bail!("Invalid DID: contains forbidden characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
||||
pub(super) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let onion = data
|
||||
.server_info
|
||||
.tor_address
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let pubkey = data.server_info.pubkey.clone();
|
||||
|
||||
if onion.is_empty() {
|
||||
anyhow::bail!("Tor address not available. Tor may not be running.");
|
||||
}
|
||||
|
||||
let code = federation::create_invite(&self.config.data_dir, &did, &onion, &pubkey).await?;
|
||||
|
||||
info!(did = %did, "Generated federation invite");
|
||||
Ok(serde_json::json!({
|
||||
"code": code,
|
||||
"did": did,
|
||||
"onion": onion,
|
||||
}))
|
||||
}
|
||||
|
||||
/// federation.join — Accept an invite code and establish federation with the remote node.
|
||||
pub(super) async fn handle_federation_join(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let code = params
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'code' parameter"))?;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||
let local_pubkey = data.server_info.pubkey.clone();
|
||||
|
||||
let node = federation::accept_invite(
|
||||
&self.config.data_dir,
|
||||
code,
|
||||
&local_did,
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(peer_did = %node.did, "Joined federation with peer");
|
||||
|
||||
// Store federation membership as DWN message
|
||||
if let Ok(store) = DwnStore::new(&self.config.data_dir).await {
|
||||
let dwn_data = serde_json::json!({
|
||||
"node_did": node.did,
|
||||
"trust_level": node.trust_level.to_string(),
|
||||
"joined_at": chrono::Utc::now().to_rfc3339(),
|
||||
"apps": [],
|
||||
});
|
||||
if let Err(e) = store
|
||||
.write_message(
|
||||
&local_did,
|
||||
Some(FEDERATION_PROTOCOL),
|
||||
Some("https://archipelago.dev/schemas/federation-membership/v1"),
|
||||
Some("application/json"),
|
||||
Some(dwn_data),
|
||||
)
|
||||
.await
|
||||
{
|
||||
debug!("DWN federation membership write (non-fatal): {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Issue a federation trust VC attesting the peer relationship
|
||||
{
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let peer_did = node.did.clone();
|
||||
let issuer_did_vc = local_did.clone();
|
||||
tokio::spawn(async move {
|
||||
let claims = serde_json::json!({
|
||||
"federationPeer": true,
|
||||
"establishedAt": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
match credentials::issue_credential(
|
||||
&data_dir,
|
||||
&issuer_did_vc,
|
||||
&peer_did,
|
||||
"FederationTrustCredential",
|
||||
claims,
|
||||
None,
|
||||
|bytes| {
|
||||
// Sign with node identity key
|
||||
let identity_dir = data_dir.join("identity");
|
||||
tokio::task::block_in_place(|| {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
let id = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
Ok(id.sign(bytes))
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(vc) => debug!(vc_id = %vc.id, peer = %peer_did, "Issued federation trust VC"),
|
||||
Err(e) => debug!(error = %e, "Federation trust VC issuance failed (non-fatal)"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"joined": true,
|
||||
"node": {
|
||||
"did": node.did,
|
||||
"onion": node.onion,
|
||||
"pubkey": node.pubkey,
|
||||
"trust_level": node.trust_level.to_string(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// federation.list-nodes — List all federated nodes with their status, last state, and VC verification.
|
||||
pub(super) async fn handle_federation_list_nodes(&self) -> Result<serde_json::Value> {
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
|
||||
// Load credentials to check for federation VCs
|
||||
let cred_store = credentials::load_credentials(&self.config.data_dir).await.ok();
|
||||
let vc_subjects: std::collections::HashSet<String> = cred_store
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
s.credentials
|
||||
.iter()
|
||||
.filter(|vc| {
|
||||
vc.credential_type.iter().any(|t| t == "FederationTrustCredential")
|
||||
&& !credentials::is_revoked(vc)
|
||||
})
|
||||
.map(|vc| vc.credential_subject.id.clone())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let nodes_json: Vec<serde_json::Value> = nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let mut obj = serde_json::json!({
|
||||
"did": n.did,
|
||||
"pubkey": n.pubkey,
|
||||
"onion": n.onion,
|
||||
"trust_level": n.trust_level.to_string(),
|
||||
"added_at": n.added_at,
|
||||
"vc_verified": vc_subjects.contains(&n.did),
|
||||
});
|
||||
if let Some(name) = &n.name {
|
||||
obj["name"] = serde_json::json!(name);
|
||||
}
|
||||
if let Some(last_seen) = &n.last_seen {
|
||||
obj["last_seen"] = serde_json::json!(last_seen);
|
||||
}
|
||||
if let Some(state) = &n.last_state {
|
||||
obj["last_state"] = serde_json::to_value(state).unwrap_or_default();
|
||||
}
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "nodes": nodes_json }))
|
||||
}
|
||||
|
||||
/// federation.remove-node — Remove a node from the federation by DID.
|
||||
pub(super) async fn handle_federation_remove_node(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let did = params
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'did' parameter"))?;
|
||||
validate_did(did)?;
|
||||
|
||||
let nodes = federation::remove_node(&self.config.data_dir, did).await?;
|
||||
info!(did = %did, "Removed node from federation");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"removed": true,
|
||||
"nodes_remaining": nodes.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// federation.set-trust — Change trust level for a federated node.
|
||||
pub(super) async fn handle_federation_set_trust(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let did = params
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'did' parameter"))?;
|
||||
validate_did(did)?;
|
||||
let trust_str = params
|
||||
.get("trust_level")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'trust_level' parameter"))?;
|
||||
|
||||
let trust = match trust_str {
|
||||
"trusted" => TrustLevel::Trusted,
|
||||
"observer" => TrustLevel::Observer,
|
||||
"untrusted" => TrustLevel::Untrusted,
|
||||
_ => anyhow::bail!("Invalid trust level: {} (expected trusted/observer/untrusted)", trust_str),
|
||||
};
|
||||
|
||||
federation::set_trust_level(&self.config.data_dir, did, trust).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"updated": true,
|
||||
"did": did,
|
||||
"trust_level": trust.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// federation.sync-state — Manually trigger state sync with all federated peers.
|
||||
pub(super) async fn handle_federation_sync_state(&self) -> Result<serde_json::Value> {
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
|
||||
if nodes.is_empty() {
|
||||
return Ok(serde_json::json!({
|
||||
"synced": 0,
|
||||
"failed": 0,
|
||||
"results": [],
|
||||
}));
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
|
||||
let mut synced = 0u32;
|
||||
let mut failed = 0u32;
|
||||
let mut results = Vec::new();
|
||||
|
||||
for node in &nodes {
|
||||
if node.trust_level == TrustLevel::Untrusted {
|
||||
continue;
|
||||
}
|
||||
|
||||
let did_clone = local_did.clone();
|
||||
match federation::sync_with_peer(
|
||||
&self.config.data_dir,
|
||||
node,
|
||||
&did_clone,
|
||||
|bytes| node_identity.sign(bytes),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(state) => {
|
||||
synced += 1;
|
||||
results.push(serde_json::json!({
|
||||
"did": node.did,
|
||||
"status": "ok",
|
||||
"apps": state.apps.len(),
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
results.push(serde_json::json!({
|
||||
"did": node.did,
|
||||
"status": "error",
|
||||
"error": e.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"synced": synced,
|
||||
"failed": failed,
|
||||
"results": results,
|
||||
}))
|
||||
}
|
||||
|
||||
/// federation.get-state — Return this node's state snapshot (called by peers during sync).
|
||||
pub(super) async fn handle_federation_get_state(&self) -> Result<serde_json::Value> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
|
||||
// Build app statuses from package_data
|
||||
let apps: Vec<federation::AppStatus> = data
|
||||
.package_data
|
||||
.iter()
|
||||
.map(|(id, pkg)| federation::AppStatus {
|
||||
id: id.clone(),
|
||||
status: format!("{:?}", pkg.state).to_lowercase(),
|
||||
version: Some(pkg.manifest.version.clone()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tor_active = data.server_info.tor_address.is_some();
|
||||
|
||||
let state = federation::build_local_state(
|
||||
apps, 0.0, 0, 0, 0, 0, 0, tor_active,
|
||||
);
|
||||
|
||||
Ok(serde_json::to_value(&state)?)
|
||||
}
|
||||
|
||||
/// federation.peer-joined — Called by a remote peer after they accept our invite.
|
||||
pub(super) async fn handle_federation_peer_joined(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let did = params
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'did'"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'onion'"))?;
|
||||
let pubkey = params
|
||||
.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey'"))?;
|
||||
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
if nodes.iter().any(|n| n.did == did) {
|
||||
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
|
||||
}
|
||||
|
||||
let node = FederatedNode {
|
||||
did: did.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
onion: onion.to_string(),
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
};
|
||||
|
||||
federation::add_node(&self.config.data_dir, node).await?;
|
||||
info!(peer_did = %did, "Peer joined our federation");
|
||||
|
||||
Ok(serde_json::json!({ "accepted": true }))
|
||||
}
|
||||
|
||||
/// federation.deploy-app — Deploy an app to a remote federated node.
|
||||
pub(super) async fn handle_federation_deploy_app(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let peer_did = params
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'did' (target node)"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'app_id'"))?;
|
||||
let version = params
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("latest");
|
||||
let marketplace_url = params
|
||||
.get("marketplace_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
let peer = nodes
|
||||
.iter()
|
||||
.find(|n| n.did == peer_did)
|
||||
.ok_or_else(|| anyhow::anyhow!("No federated node with DID {}", peer_did))?;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
|
||||
let result = federation::deploy_to_peer(
|
||||
peer,
|
||||
app_id,
|
||||
version,
|
||||
marketplace_url,
|
||||
&local_did,
|
||||
|bytes| node_identity.sign(bytes),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(app = %app_id, peer = %peer_did, "Deployed app to federated peer");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// federation.peer-address-changed — A peer notifies us that their .onion changed.
|
||||
pub(super) async fn handle_federation_peer_address_changed(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let did = params
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing did"))?;
|
||||
let new_onion = params
|
||||
.get("new_onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing new_onion"))?;
|
||||
|
||||
// Load existing nodes, find the peer by DID, update their onion
|
||||
let mut nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
let found = nodes.iter_mut().find(|n| n.did == did);
|
||||
|
||||
match found {
|
||||
Some(node) => {
|
||||
let old = node.onion.clone();
|
||||
node.onion = new_onion.to_string();
|
||||
federation::save_nodes(&self.config.data_dir, &nodes).await?;
|
||||
info!(did = %did, old_onion = %old, new_onion = %new_onion, "Updated federated peer address");
|
||||
Ok(serde_json::json!({
|
||||
"updated": true,
|
||||
"did": did,
|
||||
"old_onion": old,
|
||||
"new_onion": new_onion,
|
||||
}))
|
||||
}
|
||||
None => {
|
||||
info!(did = %did, "Received address change from unknown peer — ignoring");
|
||||
Ok(serde_json::json!({
|
||||
"updated": false,
|
||||
"reason": "Unknown peer DID",
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
149
core/archipelago/src/api/rpc/handshake.rs
Normal file
149
core/archipelago/src/api/rpc/handshake.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use super::RpcHandler;
|
||||
use crate::{nostr_handshake, peers};
|
||||
use anyhow::Result;
|
||||
use nostr_sdk::FromBech32;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Discover nodes (presence-only — returns Nostr pubkeys + DIDs, no onion addresses).
|
||||
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let nodes = nostr_handshake::discover_nodes(
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(serde_json::json!({ "nodes": nodes }))
|
||||
}
|
||||
|
||||
/// Send encrypted connection request to a peer's Nostr pubkey.
|
||||
/// Params: { recipient_nostr_pubkey }
|
||||
pub(super) async fn handle_handshake_connect(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
// Accept either hex pubkey or npub1... bech32 format
|
||||
let recipient_raw = params
|
||||
.get("recipient_nostr_pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing recipient_nostr_pubkey"))?;
|
||||
let recipient = if recipient_raw.starts_with("npub1") {
|
||||
nostr_sdk::PublicKey::from_bech32(recipient_raw)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid npub: {}", e))?
|
||||
.to_hex()
|
||||
} else {
|
||||
recipient_raw.to_string()
|
||||
};
|
||||
let recipient = recipient.as_str();
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let our_onion = data
|
||||
.server_info
|
||||
.tor_address
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No Tor address available — is Tor running?"))?;
|
||||
let our_node_pubkey = &data.server_info.pubkey;
|
||||
let our_did = crate::identity::did_key_from_pubkey_hex(our_node_pubkey)
|
||||
.unwrap_or_default();
|
||||
let our_version = &data.server_info.version;
|
||||
let our_name = data.server_info.name.as_deref();
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
nostr_handshake::send_connect_request(
|
||||
&identity_dir,
|
||||
recipient,
|
||||
our_onion,
|
||||
our_node_pubkey,
|
||||
&our_did,
|
||||
our_version,
|
||||
our_name,
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "sent_to": recipient }))
|
||||
}
|
||||
|
||||
/// Poll for incoming encrypted handshake messages (connect requests/responses).
|
||||
/// Auto-adds peers and auto-responds to requests.
|
||||
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let handshakes = nostr_handshake::poll_handshakes(
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
None, // TODO: track last-seen timestamp to avoid re-processing
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let mut added_peers = Vec::new();
|
||||
|
||||
for hs in &handshakes {
|
||||
let (onion, node_pubkey, name) = match &hs.message {
|
||||
nostr_handshake::HandshakeMessage::ConnectRequest {
|
||||
onion,
|
||||
node_pubkey,
|
||||
name,
|
||||
..
|
||||
} => {
|
||||
// Auto-respond with our details
|
||||
if let Some(our_onion) = data.server_info.tor_address.as_deref() {
|
||||
let our_did = crate::identity::did_key_from_pubkey_hex(
|
||||
&data.server_info.pubkey,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let _ = nostr_handshake::send_connect_response(
|
||||
&identity_dir,
|
||||
&hs.from_nostr_pubkey,
|
||||
our_onion,
|
||||
&data.server_info.pubkey,
|
||||
&our_did,
|
||||
&data.server_info.version,
|
||||
data.server_info.name.as_deref(),
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
(onion.clone(), node_pubkey.clone(), name.clone())
|
||||
}
|
||||
nostr_handshake::HandshakeMessage::ConnectResponse {
|
||||
onion,
|
||||
node_pubkey,
|
||||
name,
|
||||
..
|
||||
} => (onion.clone(), node_pubkey.clone(), name.clone()),
|
||||
};
|
||||
|
||||
// Auto-add as peer
|
||||
let peer = peers::KnownPeer {
|
||||
onion,
|
||||
pubkey: node_pubkey.clone(),
|
||||
name,
|
||||
added_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||
};
|
||||
let _ = peers::add_peer(&self.config.data_dir, peer).await;
|
||||
added_peers.push(node_pubkey);
|
||||
}
|
||||
|
||||
let serialized: Vec<serde_json::Value> = handshakes
|
||||
.iter()
|
||||
.map(|hs| {
|
||||
serde_json::json!({
|
||||
"from_nostr_pubkey": hs.from_nostr_pubkey,
|
||||
"from_nostr_npub": hs.from_nostr_npub,
|
||||
"message": hs.message,
|
||||
"timestamp": hs.timestamp,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"handshakes": serialized,
|
||||
"added_peers": added_peers,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,24 @@
|
||||
//! RPC handlers for multi-identity management.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::identity_manager::{IdentityManager, IdentityPurpose};
|
||||
use anyhow::Result;
|
||||
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
|
||||
use crate::network::did_dht;
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_sdk::ToBech32;
|
||||
|
||||
/// Validate an identity ID: alphanumeric, hyphens, underscores, 1-128 chars, no path traversal.
|
||||
fn validate_identity_id(id: &str) -> Result<()> {
|
||||
if id.is_empty() || id.len() > 128 {
|
||||
anyhow::bail!("Invalid identity id: must be 1-128 characters");
|
||||
}
|
||||
if id.contains("..") || id.contains('/') || id.contains('\\') || id.contains('\0') {
|
||||
anyhow::bail!("Invalid identity id: contains forbidden characters");
|
||||
}
|
||||
if !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':') {
|
||||
anyhow::bail!("Invalid identity id: must be alphanumeric, hyphens, underscores, or colons");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// List all identities with their default status.
|
||||
@@ -25,6 +41,9 @@ impl RpcHandler {
|
||||
"did": id.did,
|
||||
"created_at": id.created_at,
|
||||
"is_default": is_default,
|
||||
"nostr_pubkey": id.nostr_pubkey,
|
||||
"nostr_npub": id.nostr_npub,
|
||||
"profile": id.profile,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -65,6 +84,8 @@ impl RpcHandler {
|
||||
"pubkey": record.pubkey_hex,
|
||||
"did": record.did,
|
||||
"created_at": record.created_at,
|
||||
"nostr_pubkey": record.nostr_pubkey,
|
||||
"nostr_npub": record.nostr_npub,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -78,6 +99,7 @@ impl RpcHandler {
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let record = manager.get(id).await?;
|
||||
@@ -92,6 +114,8 @@ impl RpcHandler {
|
||||
"did": record.did,
|
||||
"created_at": record.created_at,
|
||||
"is_default": is_default,
|
||||
"nostr_pubkey": record.nostr_pubkey,
|
||||
"nostr_npub": record.nostr_npub,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -105,6 +129,7 @@ impl RpcHandler {
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
manager.delete(id).await?;
|
||||
@@ -122,6 +147,7 @@ impl RpcHandler {
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
manager.set_default(id).await?;
|
||||
@@ -180,6 +206,111 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "valid": valid }))
|
||||
}
|
||||
|
||||
/// Resolve a DID to its W3C DID Document.
|
||||
/// If no DID is provided, returns the node's own DID Document.
|
||||
pub(super) async fn handle_identity_resolve_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
|
||||
// If a DID is provided, resolve it; otherwise use the node's DID
|
||||
let is_local = params.get("did").and_then(|v| v.as_str()).is_none();
|
||||
let pubkey_hex = if let Some(did) = params.get("did").and_then(|v| v.as_str()) {
|
||||
let pubkey_bytes = crate::identity::pubkey_bytes_from_did_key(did)?;
|
||||
hex::encode(pubkey_bytes)
|
||||
} else {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
data.server_info.pubkey.clone()
|
||||
};
|
||||
|
||||
// For local node, include Nostr secp256k1 key in DID Document (paired identity)
|
||||
let document = if is_local {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
match crate::nostr_discovery::get_nostr_pubkey(&identity_dir).await {
|
||||
Ok(nostr_pubkey) => {
|
||||
crate::identity::did_document_with_nostr(&pubkey_hex, &nostr_pubkey)?
|
||||
}
|
||||
Err(_) => crate::identity::did_document_from_pubkey_hex(&pubkey_hex)?,
|
||||
}
|
||||
} else {
|
||||
crate::identity::did_document_from_pubkey_hex(&pubkey_hex)?
|
||||
};
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
/// Verify a DID Document: validate structure, check key material matches DID.
|
||||
pub(super) async fn handle_identity_verify_did_document(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let document = params
|
||||
.get("document")
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: document"))?;
|
||||
|
||||
// Validate required fields
|
||||
let did = document["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("DID Document missing 'id' field"))?;
|
||||
|
||||
let context = document["@context"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("DID Document missing '@context' array"))?;
|
||||
|
||||
let has_did_context = context.iter().any(|c| c.as_str() == Some("https://www.w3.org/ns/did/v1"));
|
||||
if !has_did_context {
|
||||
return Ok(serde_json::json!({
|
||||
"valid": false,
|
||||
"errors": ["Missing required @context: https://www.w3.org/ns/did/v1"]
|
||||
}));
|
||||
}
|
||||
|
||||
let verification_methods = document["verificationMethod"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("DID Document missing 'verificationMethod' array"))?;
|
||||
|
||||
if verification_methods.is_empty() {
|
||||
return Ok(serde_json::json!({
|
||||
"valid": false,
|
||||
"errors": ["verificationMethod array is empty"]
|
||||
}));
|
||||
}
|
||||
|
||||
// Verify the DID matches the key material (for did:key method)
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
|
||||
if did.starts_with("did:key:") {
|
||||
match crate::identity::pubkey_bytes_from_did_key(did) {
|
||||
Ok(pubkey_bytes) => {
|
||||
// Check that at least one verification method has matching key
|
||||
let pubkey_multibase = format!("z{}", bs58::encode(&pubkey_bytes).into_string());
|
||||
let has_matching_key = verification_methods.iter().any(|vm| {
|
||||
vm["publicKeyMultibase"].as_str() == Some(&pubkey_multibase)
|
||||
});
|
||||
if !has_matching_key {
|
||||
errors.push("No verificationMethod matches the DID's public key".to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("Failed to extract pubkey from DID: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check authentication is present
|
||||
if document["authentication"].as_array().map_or(true, |a| a.is_empty()) {
|
||||
errors.push("Missing or empty 'authentication' field".to_string());
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"valid": errors.is_empty(),
|
||||
"did": did,
|
||||
"errors": errors,
|
||||
"verification_methods": verification_methods.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create a Nostr keypair linked to an identity.
|
||||
pub(super) async fn handle_identity_create_nostr_key(
|
||||
&self,
|
||||
@@ -192,33 +323,451 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let pubkey = manager.create_nostr_key(id).await?;
|
||||
let pubkey_hex = manager.create_nostr_key(id).await?;
|
||||
|
||||
// Derive npub (bech32 NIP-19) from hex
|
||||
let npub = nostr_sdk::PublicKey::from_hex(&pubkey_hex)
|
||||
.ok()
|
||||
.and_then(|pk| pk.to_bech32().ok());
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"nostr_pubkey": pubkey,
|
||||
"nostr_pubkey": pubkey_hex,
|
||||
"nostr_npub": npub,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Sign a Nostr event hash with an identity's Nostr key.
|
||||
/// Sign a Nostr event with an identity's Nostr key.
|
||||
///
|
||||
/// Accepts either:
|
||||
/// - `event_hash` (hex) + `id` — sign a pre-computed hash
|
||||
/// - `event` (full event object) — compute NIP-01 hash, fill pubkey, sign
|
||||
/// If `id` is omitted, uses the default identity.
|
||||
pub(super) async fn handle_identity_nostr_sign(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let (records, _) = manager.list().await?;
|
||||
|
||||
// Resolve identity: prefer explicit id, then default, then any with Nostr key
|
||||
let id = if let Some(id) = params.get("id").and_then(|v| v.as_str()) {
|
||||
id.to_string()
|
||||
} else {
|
||||
// Prefer an identity with a Nostr key
|
||||
records.iter()
|
||||
.find(|r| r.nostr_pubkey.is_some())
|
||||
.map(|r| r.id.clone())
|
||||
.ok_or_else(|| anyhow::anyhow!("No identity with Nostr key found"))?
|
||||
};
|
||||
|
||||
let identity = records.iter().find(|r| r.id == id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Identity not found: {}", id))?;
|
||||
let pubkey_hex = identity.nostr_pubkey.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("Identity has no Nostr key"))?;
|
||||
|
||||
if let Some(event_hash) = params.get("event_hash").and_then(|v| v.as_str()) {
|
||||
// Direct hash signing
|
||||
let signature = manager.nostr_sign(&id, event_hash).await?;
|
||||
return Ok(serde_json::json!({ "signature": signature }));
|
||||
}
|
||||
|
||||
// Full event signing: compute NIP-01 event hash
|
||||
let event = params.get("event")
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'event' or 'event_hash' parameter"))?;
|
||||
|
||||
let kind = event.get("kind").and_then(|v| v.as_u64()).unwrap_or(1);
|
||||
let content = event.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let created_at = event.get("created_at").and_then(|v| v.as_u64())
|
||||
.unwrap_or_else(|| std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs());
|
||||
let tags = event.get("tags").cloned().unwrap_or_else(|| serde_json::json!([]));
|
||||
|
||||
// NIP-01 serialization: [0, pubkey, created_at, kind, tags, content]
|
||||
let serialized = serde_json::json!([0, pubkey_hex, created_at, kind, tags, content]);
|
||||
let serialized_str = serde_json::to_string(&serialized)?;
|
||||
|
||||
// SHA-256 hash
|
||||
use sha2::{Sha256, Digest};
|
||||
let hash = Sha256::digest(serialized_str.as_bytes());
|
||||
let event_hash_hex = hex::encode(hash);
|
||||
|
||||
let signature = manager.nostr_sign(&id, &event_hash_hex).await?;
|
||||
|
||||
// Return the complete signed event
|
||||
Ok(serde_json::json!({
|
||||
"id": event_hash_hex,
|
||||
"pubkey": pubkey_hex,
|
||||
"created_at": created_at,
|
||||
"kind": kind,
|
||||
"tags": tags,
|
||||
"content": content,
|
||||
"sig": signature,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Resolve the identity ID from params, falling back to the default identity.
|
||||
async fn resolve_identity_id(&self, params: &serde_json::Value) -> Result<String> {
|
||||
if let Some(id) = params.get("id").and_then(|v| v.as_str()) {
|
||||
return Ok(id.to_string());
|
||||
}
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let (records, default_id) = manager.list().await?;
|
||||
// Prefer the default identity
|
||||
if let Some(default_id) = default_id {
|
||||
return Ok(default_id);
|
||||
}
|
||||
// Fall back to first identity with a Nostr key, or just the first identity
|
||||
records.iter()
|
||||
.find(|i| i.nostr_pubkey.is_some())
|
||||
.or(records.first())
|
||||
.map(|i| i.id.clone())
|
||||
.ok_or_else(|| anyhow::anyhow!("No identity found"))
|
||||
}
|
||||
|
||||
/// NIP-04 encrypt plaintext for a peer.
|
||||
pub(super) async fn handle_identity_nostr_encrypt_nip04(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = self.resolve_identity_id(¶ms).await?;
|
||||
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
||||
let plaintext = params.get("plaintext").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: plaintext"))?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let ciphertext = manager.nostr_encrypt_nip04(&id, pubkey, plaintext).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ciphertext": ciphertext }))
|
||||
}
|
||||
|
||||
/// NIP-04 decrypt ciphertext from a peer.
|
||||
pub(super) async fn handle_identity_nostr_decrypt_nip04(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = self.resolve_identity_id(¶ms).await?;
|
||||
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
||||
let ciphertext = params.get("ciphertext").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ciphertext"))?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let plaintext = manager.nostr_decrypt_nip04(&id, pubkey, ciphertext).await?;
|
||||
|
||||
Ok(serde_json::json!({ "plaintext": plaintext }))
|
||||
}
|
||||
|
||||
/// NIP-44 encrypt plaintext for a peer.
|
||||
pub(super) async fn handle_identity_nostr_encrypt_nip44(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = self.resolve_identity_id(¶ms).await?;
|
||||
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
||||
let plaintext = params.get("plaintext").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: plaintext"))?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let ciphertext = manager.nostr_encrypt_nip44(&id, pubkey, plaintext).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ciphertext": ciphertext }))
|
||||
}
|
||||
|
||||
/// NIP-44 decrypt ciphertext from a peer.
|
||||
pub(super) async fn handle_identity_nostr_decrypt_nip44(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = self.resolve_identity_id(¶ms).await?;
|
||||
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
||||
let ciphertext = params.get("ciphertext").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ciphertext"))?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let plaintext = manager.nostr_decrypt_nip44(&id, pubkey, ciphertext).await?;
|
||||
|
||||
Ok(serde_json::json!({ "plaintext": plaintext }))
|
||||
}
|
||||
|
||||
/// Resolve a remote peer's DID Document over Tor.
|
||||
/// Queries the peer's /rpc/ endpoint for identity.resolve-did.
|
||||
pub(super) async fn handle_identity_resolve_remote_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: onion"))?;
|
||||
|
||||
// Build URL for peer's RPC endpoint over Tor
|
||||
let host = if onion.ends_with(".onion") {
|
||||
onion.to_string()
|
||||
} else {
|
||||
format!("{}.onion", onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/", host);
|
||||
|
||||
// Use SOCKS5 proxy to reach .onion address
|
||||
let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
|
||||
.context("Failed to create Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let rpc_body = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "identity.resolve-did",
|
||||
"params": {}
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&rpc_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to peer over Tor")?;
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse peer response")?;
|
||||
|
||||
// Extract the DID Document from the RPC response
|
||||
let document = body
|
||||
.get("result")
|
||||
.ok_or_else(|| anyhow::anyhow!("Peer returned error or missing result"))?;
|
||||
|
||||
// Cache the resolved DID locally
|
||||
let did = document["id"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown");
|
||||
let cache_dir = self.config.data_dir.join("did-cache");
|
||||
tokio::fs::create_dir_all(&cache_dir).await.ok();
|
||||
let cache_file = cache_dir.join(format!("{}.json", onion.replace('.', "_")));
|
||||
let cache_entry = serde_json::json!({
|
||||
"document": document,
|
||||
"resolved_at": chrono::Utc::now().to_rfc3339(),
|
||||
"onion": onion,
|
||||
});
|
||||
tokio::fs::write(&cache_file, serde_json::to_string_pretty(&cache_entry).unwrap_or_default())
|
||||
.await
|
||||
.ok();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"document": document,
|
||||
"did": did,
|
||||
"resolved_from": onion,
|
||||
"cached": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// identity.create-dht-did — Publish an identity's DID to the Mainline DHT.
|
||||
pub(super) async fn handle_identity_create_dht_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let identity_id = params
|
||||
.get("identity_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing identity_id"))?;
|
||||
validate_identity_id(identity_id)?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let signing_key = manager.get_signing_key(identity_id).await?;
|
||||
|
||||
let dht_did = did_dht::create_and_publish(&signing_key, &[]).await?;
|
||||
|
||||
// Save the dht_did back to the identity record
|
||||
did_dht::save_dht_did(&self.config.data_dir, identity_id, &dht_did).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"dht_did": dht_did,
|
||||
"published": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// identity.resolve-dht-did — Resolve a did:dht from the DHT.
|
||||
pub(super) async fn handle_identity_resolve_dht_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let did = params
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing did"))?;
|
||||
|
||||
if !did.starts_with("did:dht:") {
|
||||
anyhow::bail!("Not a did:dht identifier");
|
||||
}
|
||||
|
||||
let doc = did_dht::resolve(did, None).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"did": did,
|
||||
"document": doc,
|
||||
}))
|
||||
}
|
||||
|
||||
/// identity.refresh-dht-did — Re-publish an identity's did:dht to keep it alive in the DHT.
|
||||
pub(super) async fn handle_identity_refresh_dht_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let identity_id = params
|
||||
.get("identity_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing identity_id"))?;
|
||||
validate_identity_id(identity_id)?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let record = manager.get(identity_id).await?;
|
||||
|
||||
if record.dht_did.is_none() {
|
||||
anyhow::bail!("Identity has no did:dht — create one first with identity.create-dht-did");
|
||||
}
|
||||
|
||||
let signing_key = manager.get_signing_key(identity_id).await?;
|
||||
let dht_did = did_dht::create_and_publish(&signing_key, &[]).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"dht_did": dht_did,
|
||||
"refreshed": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update profile metadata for an identity.
|
||||
pub(super) async fn handle_identity_update_profile(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params.get("id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let profile = IdentityProfile {
|
||||
display_name: params.get("display_name").and_then(|v| v.as_str()).map(String::from),
|
||||
about: params.get("about").and_then(|v| v.as_str()).map(String::from),
|
||||
picture: params.get("picture").and_then(|v| v.as_str()).map(String::from),
|
||||
banner: params.get("banner").and_then(|v| v.as_str()).map(String::from),
|
||||
website: params.get("website").and_then(|v| v.as_str()).map(String::from),
|
||||
nip05: params.get("nip05").and_then(|v| v.as_str()).map(String::from),
|
||||
lud16: params.get("lud16").and_then(|v| v.as_str()).map(String::from),
|
||||
};
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
manager.update_profile(id, profile).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Publish kind 0 (metadata) profile to the local Nostr relay.
|
||||
pub(super) async fn handle_identity_publish_profile(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params.get("id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let relay_url = params.get("relay")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("ws://localhost:18081");
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let event_id = manager.publish_profile(id, relay_url).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"event_id": event_id,
|
||||
"relay": relay_url,
|
||||
"published": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Export private keys for an identity — REQUIRES password verification.
|
||||
pub(super) async fn handle_identity_export_keys(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
let event_hash = params
|
||||
.get("event_hash")
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: event_hash"))?;
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
// Verify password against auth system
|
||||
if !self.auth_manager.verify_password(password).await? {
|
||||
anyhow::bail!("Invalid password");
|
||||
}
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let signature = manager.nostr_sign(id, event_hash).await?;
|
||||
let keys = manager.export_keys(id).await?;
|
||||
let record = manager.get(id).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"signature": signature,
|
||||
"id": record.id,
|
||||
"name": record.name,
|
||||
"pubkey": record.pubkey_hex,
|
||||
"did": record.did,
|
||||
"nostr_pubkey": record.nostr_pubkey,
|
||||
"nostr_npub": record.nostr_npub,
|
||||
"ed25519_secret_hex": keys["ed25519_secret_hex"],
|
||||
"nostr_secret_hex": keys["nostr_secret_hex"],
|
||||
"nostr_nsec": keys["nostr_nsec"],
|
||||
}))
|
||||
}
|
||||
|
||||
/// identity.dht-status — Check if an identity's did:dht is published and resolvable.
|
||||
pub(super) async fn handle_identity_dht_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let identity_id = params
|
||||
.get("identity_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing identity_id"))?;
|
||||
validate_identity_id(identity_id)?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let record = manager.get(identity_id).await?;
|
||||
|
||||
let (published, resolvable) = match &record.dht_did {
|
||||
Some(dht_did) => {
|
||||
let resolvable = did_dht::resolve(dht_did, None).await.is_ok();
|
||||
(true, resolvable)
|
||||
}
|
||||
None => (false, false),
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"identity_id": identity_id,
|
||||
"did_key": record.did,
|
||||
"dht_did": record.dht_did,
|
||||
"published": published,
|
||||
"resolvable": resolvable,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user