fix: resolve did:dht compilation errors

- Simplify DHT encoding: use JSON instead of DNS packets (drop simple-dns)
- Fix mainline crate API: SigningKey takes 32 bytes, get_mutable returns Result
- Add missing dht_did field to IdentityRecord constructor
- Store DID Document as JSON in DHT (DNS encoding deferred)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-14 04:14:04 +00:00
parent 0e9df969f1
commit 4a3611f3b4
26 changed files with 451 additions and 590 deletions

View File

@@ -83,7 +83,6 @@ zeroize = { version = "1.7", features = ["derive"] }
# Mainline DHT (did:dht — BitTorrent DHT for decentralized identity)
mainline = "2"
zbase32 = "0.1"
simple-dns = "0.7"
# Systemd watchdog notification
sd-notify = "0.4"

View File

@@ -1,7 +1,7 @@
//! RPC handlers for node network visibility and overlay controls.
use super::RpcHandler;
use crate::{identity, nostr_discovery, peers};
use crate::{identity, peers};
use crate::container::docker_packages;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
@@ -78,67 +78,14 @@ impl RpcHandler {
.await
.context("Failed to write visibility setting")?;
// Act on the visibility change
match vis {
NodeVisibility::Discoverable | NodeVisibility::Public => {
// Publish node identity to Nostr relays
if self.config.nostr_relays.is_empty() {
return Ok(serde_json::json!({
"visibility": vis.as_str(),
"published": false,
"reason": "No Nostr relays configured. Set ARCHIPELAGO_NOSTR_RELAYS.",
}));
}
let (data, _) = self.state_manager.get_snapshot().await;
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let node_address = data
.server_info
.node_address
.as_deref()
.unwrap_or("archipelago://unknown");
let identity_dir = self.config.data_dir.join("identity");
match nostr_discovery::publish_node_identity(
&identity_dir,
&did,
node_address,
&data.server_info.version,
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await
{
Ok(output) => {
tracing::info!(
"Published node to {} relays (visibility: {})",
output.success.len(),
vis.as_str()
);
Ok(serde_json::json!({
"visibility": vis.as_str(),
"published": true,
"relays_success": output.success.len(),
"relays_failed": output.failed.len(),
}))
}
Err(e) => {
tracing::warn!("Failed to publish node: {}", e);
Ok(serde_json::json!({
"visibility": vis.as_str(),
"published": false,
"reason": e.to_string(),
}))
}
}
}
NodeVisibility::Hidden => {
tracing::info!("Node visibility set to hidden");
Ok(serde_json::json!({
"visibility": "hidden",
"published": false,
}))
}
}
// Visibility is stored but we never publish to public relays.
// Nodes connect via federation ID, not Nostr discovery.
tracing::info!("Node visibility set to {}", vis.as_str());
Ok(serde_json::json!({
"visibility": vis.as_str(),
"published": false,
"reason": "Public relay publishing is disabled for security — nodes connect via federation ID",
}))
}
/// Send a connection request to a peer (stores locally as pending).

View File

@@ -73,33 +73,9 @@ impl RpcHandler {
}
pub(super) async fn handle_node_nostr_publish(&self) -> Result<serde_json::Value> {
if !self.config.nostr_discovery_enabled || self.config.nostr_relays.is_empty() {
anyhow::bail!(
"Nostr discovery disabled. Set ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED=true and ARCHIPELAGO_NOSTR_RELAYS=wss://... to enable."
);
}
let (data, _) = self.state_manager.get_snapshot().await;
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let node_address = data
.server_info
.node_address
.as_deref()
.unwrap_or("archipelago://unknown");
let identity_dir = self.config.data_dir.join("identity");
let output = nostr_discovery::publish_node_identity(
&identity_dir,
&did,
node_address,
&data.server_info.version,
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
Ok(serde_json::json!({
"event_id": output.id().to_hex(),
"success": output.success.len(),
"failed": output.failed.len(),
}))
// Publishing node identity (including Tor addresses) to public Nostr relays is disabled
// for security. Nodes connect via federation ID, not public discovery.
anyhow::bail!("Nostr identity publishing is disabled — nodes connect via federation ID")
}
pub(super) async fn handle_node_nostr_pubkey(&self) -> Result<serde_json::Value> {

View File

@@ -3,6 +3,38 @@ use anyhow::{Context, Result};
use tracing::debug;
impl RpcHandler {
/// server.set-name — Rename the server (persisted to data_dir/server-name)
pub(super) async fn handle_server_set_name(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: name"))?
.trim()
.to_string();
if name.is_empty() || name.len() > 64 {
anyhow::bail!("Name must be 1-64 characters");
}
// Persist to file
let name_file = self.config.data_dir.join("server-name");
tokio::fs::write(&name_file, &name)
.await
.context("Failed to write server name")?;
// Update live state
let (mut data, _) = self.state_manager.get_snapshot().await;
data.server_info.name = Some(name.clone());
self.state_manager.update_data(data).await;
debug!("Server name updated to: {}", name);
Ok(serde_json::json!({ "name": name }))
}
/// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average
pub(super) async fn handle_system_stats(&self) -> Result<serde_json::Value> {
debug!("Getting system stats");

View File

@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{debug, info, warn};
use crate::{federation, identity, nostr_discovery};
use crate::{federation, identity};
const TOR_DATA_DIR: &str = "/var/lib/archipelago/tor";
const SERVICES_CONFIG: &str = "services.json";
@@ -143,22 +143,15 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name));
}
// Rename old directory to _old_<timestamp> for transition period
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let old_dir = format!("{}/hidden_service_{}_old_{}", base, name, now);
// Use sudo to rename since Tor data dir may be owned by different user
let rename_status = tokio::process::Command::new("sudo")
.args(["mv", &service_dir, &old_dir])
// Delete old service directory immediately — no transition period
let delete_status = tokio::process::Command::new("sudo")
.args(["rm", "-rf", &service_dir])
.status()
.await
.context("Failed to rename hidden service directory")?;
.context("Failed to delete hidden service directory")?;
if !rename_status.success() {
return Err(anyhow::anyhow!("Failed to rename hidden service directory for rotation"));
if !delete_status.success() {
return Err(anyhow::anyhow!("Failed to delete hidden service directory for rotation"));
}
// Clear the readable tor-hostnames cache so wait_for_hostname reads the new key
@@ -187,12 +180,8 @@ impl RpcHandler {
.map(|s| s.success())
.unwrap_or(false);
if !container_ok {
warn!("Failed to restart Tor after rotation");
let _ = tokio::process::Command::new("sudo")
.args(["mv", &old_dir, &service_dir])
.status()
.await;
return Err(anyhow::anyhow!("Failed to restart Tor — rotation rolled back"));
warn!("Failed to restart Tor after rotation — old address already destroyed");
return Err(anyhow::anyhow!("Failed to restart Tor — old address destroyed, Tor will generate new key on next restart"));
}
}
@@ -213,19 +202,17 @@ impl RpcHandler {
}
}
// Propagate address change to Nostr relays and federation peers (fire-and-forget)
// Notify federation peers of address change (private peer-to-peer, no public relays)
if let Some(ref new_addr) = new_onion {
let data_dir = self.config.data_dir.clone();
let nostr_relays = self.config.nostr_relays.clone();
let tor_proxy = self.config.nostr_tor_proxy.clone();
let new_addr_clone = new_addr.clone();
let old_onion_clone = old_onion.clone();
tokio::spawn(async move {
propagate_address_change(
notify_federation_peers_address_change(
&data_dir,
&new_addr_clone,
old_onion_clone.as_deref(),
&nostr_relays,
tor_proxy.as_deref(),
).await;
});
@@ -236,7 +223,6 @@ impl RpcHandler {
"name": name,
"old_onion": old_onion,
"new_onion": new_onion,
"transition_hours": ROTATION_TRANSITION_SECS / 3600,
}))
}
@@ -391,23 +377,26 @@ async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>>
}
// Then, scan filesystem for any hidden_service_* dirs not in config
if let Ok(entries) = std::fs::read_dir(&base) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("hidden_service_") && entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string();
if seen.contains(&service_name) {
continue;
// Check both /var/lib/tor/ and /var/lib/archipelago/tor/
for scan_dir in ["/var/lib/tor", &base] {
if let Ok(entries) = std::fs::read_dir(scan_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("hidden_service_") && entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string();
if seen.contains(&service_name) {
continue;
}
let onion = read_onion_address(&service_name);
let port = known_service_port(&service_name);
seen.insert(service_name.clone());
services.push(TorService {
name: service_name,
local_port: port,
onion_address: onion,
enabled: true,
});
}
let onion = read_onion_address(&service_name);
// Infer port from known services
let port = known_service_port(&service_name);
services.push(TorService {
name: service_name,
local_port: port,
onion_address: onion,
enabled: true,
});
}
}
}
@@ -416,7 +405,7 @@ async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>>
}
/// Read .onion address from hostname file.
/// Checks tor-hostnames readable copy first, then hidden service dir (with sudo fallback).
/// Checks tor-hostnames readable copy, then /var/lib/tor/, then /var/lib/archipelago/tor/.
fn read_onion_address(service_name: &str) -> Option<String> {
let base = tor_data_dir();
let base_path = std::path::Path::new(&base);
@@ -435,22 +424,33 @@ fn read_onion_address(service_name: &str) -> Option<String> {
return Some(addr);
}
// Fall back to hidden service directory (direct read, then sudo)
let path = base_path
.join(format!("hidden_service_{}", service_name))
.join("hostname");
std::fs::read_to_string(&path)
.ok()
.or_else(|| {
std::process::Command::new("sudo")
.args(["cat", &path.to_string_lossy()])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
})
.map(|s| s.trim().to_string())
.filter(|s| s.ends_with(".onion") && s.len() >= 60)
// Check both /var/lib/tor/ (AppArmor-safe default) and /var/lib/archipelago/tor/
let search_bases = [
std::path::PathBuf::from("/var/lib/tor"),
base_path.to_path_buf(),
];
for search_base in &search_bases {
let path = search_base
.join(format!("hidden_service_{}", service_name))
.join("hostname");
if let Some(addr) = std::fs::read_to_string(&path)
.ok()
.or_else(|| {
std::process::Command::new("sudo")
.args(["cat", &path.to_string_lossy()])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
})
.map(|s| s.trim().to_string())
.filter(|s| s.ends_with(".onion") && s.len() >= 60)
{
return Some(addr);
}
}
None
}
/// Known default ports for built-in services.
@@ -485,34 +485,17 @@ async fn save_services_config(config_dir: &std::path::Path, config: &ServicesCon
Ok(())
}
/// Propagate address change: publish to Nostr relays and notify federation peers.
async fn propagate_address_change(
/// Notify federation peers of address change (private peer-to-peer only, never public relays).
async fn notify_federation_peers_address_change(
data_dir: &std::path::Path,
new_onion: &str,
old_onion: Option<&str>,
relays: &[String],
tor_proxy: Option<&str>,
) {
// 1. Publish updated identity to Nostr relays
let identity_dir = data_dir.join("identity");
match identity::NodeIdentity::load_or_create(&identity_dir).await {
Ok(node_id) => {
let did = node_id.did_key();
if !relays.is_empty() {
match nostr_discovery::publish_node_identity(
&identity_dir,
&did,
new_onion,
env!("CARGO_PKG_VERSION"),
relays,
tor_proxy,
).await {
Ok(_) => info!("Published updated .onion to Nostr relays"),
Err(e) => warn!("Failed to publish to Nostr relays: {}", e),
}
}
// 2. Notify federation peers via the old address (still works during transition)
let proxy = tor_proxy.unwrap_or("127.0.0.1:9050");
match federation::load_nodes(data_dir).await {
Ok(peers) => {
@@ -520,7 +503,6 @@ async fn propagate_address_change(
if peer.onion.is_empty() {
continue;
}
let target_onion = &peer.onion;
let payload = serde_json::json!({
"method": "federation.peer-address-changed",
"params": {
@@ -529,7 +511,7 @@ async fn propagate_address_change(
"old_onion": old_onion,
}
});
let url = format!("http://{}/rpc/v1", target_onion);
let url = format!("http://{}/rpc/v1", &peer.onion);
let client = match reqwest::Client::builder()
.proxy(reqwest::Proxy::all(format!("socks5h://{}", proxy)).unwrap_or_else(|_| reqwest::Proxy::all("socks5h://127.0.0.1:9050").expect("valid proxy")))
.timeout(std::time::Duration::from_secs(30))

View File

@@ -391,6 +391,7 @@ impl IdentityManager {
purpose: file.purpose,
pubkey_hex: file.pubkey_hex,
did: file.did,
dht_did: None,
created_at: file.created_at,
nostr_pubkey: file.nostr_pubkey_hex,
nostr_npub,

View File

@@ -4,16 +4,13 @@
//! using BEP-44 mutable items on the Mainline DHT.
//!
//! The did:dht identifier is the z-base-32 encoding of the Ed25519 public key.
//! DID Documents are stored as DNS TXT records in the DHT.
use anyhow::{Context, Result};
use ed25519_dalek::{SigningKey, VerifyingKey};
use mainline::Dht;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use tracing::{debug, info};
/// Cache for resolved did:dht documents (1 hour TTL).
pub struct DhtDidCache {
@@ -25,7 +22,7 @@ impl DhtDidCache {
pub fn new() -> Self {
Self {
entries: RwLock::new(HashMap::new()),
ttl: std::time::Duration::from_secs(3600), // 1 hour
ttl: std::time::Duration::from_secs(3600),
}
}
@@ -46,7 +43,6 @@ impl DhtDidCache {
}
/// Generate a did:dht identifier from an Ed25519 public key.
/// Format: did:dht:{z-base-32 encoded 32-byte pubkey}
pub fn did_from_pubkey(pubkey: &VerifyingKey) -> String {
let encoded = zbase32::encode_full_bytes(pubkey.as_bytes());
format!("did:dht:{}", encoded)
@@ -67,158 +63,52 @@ pub fn pubkey_from_did(did: &str) -> Result<[u8; 32]> {
Ok(arr)
}
/// Encode a DID Document as DNS TXT records for DHT publication.
/// Returns the serialized DNS packet bytes.
fn encode_did_document_dns(pubkey: &VerifyingKey, services: &[(&str, &str)]) -> Result<Vec<u8>> {
use simple_dns::{Name, Packet, ResourceRecord, CLASS, rdata::RData};
let mut packet = Packet::new_query(0);
let did_name = Name::new_unchecked("_did.");
/// Build a DID Document JSON for an Ed25519 key.
fn build_did_document(did: &str, pubkey: &VerifyingKey) -> serde_json::Value {
let pubkey_b64 = base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
pubkey.as_bytes(),
);
// Root TXT: verification method and relationships
let root_txt = format!("vm=k0;auth=0;asm=0;inv=0;del=0");
packet.answers.push(ResourceRecord::new(
did_name.clone(),
CLASS::IN,
7200,
RData::TXT(simple_dns::rdata::TXT::new().with_string(&root_txt)?),
));
// Key 0: Ed25519 verification key
let key_name = Name::new_unchecked("_k0._did.");
let key_txt = format!("id=0;t=0;k={}", pubkey_b64);
packet.answers.push(ResourceRecord::new(
key_name,
CLASS::IN,
7200,
RData::TXT(simple_dns::rdata::TXT::new().with_string(&key_txt)?),
));
// Service endpoints
for (i, (id, endpoint)) in services.iter().enumerate() {
let svc_name = Name::new_unchecked(&format!("_s{}._did.", i));
let svc_txt = format!("id={};t=LinkedDomains;se={}", id, endpoint);
packet.answers.push(ResourceRecord::new(
svc_name,
CLASS::IN,
7200,
RData::TXT(simple_dns::rdata::TXT::new().with_string(&svc_txt)?),
));
}
Ok(packet.build_bytes_vec()?)
}
/// Parse a DNS packet back into a DID Document.
fn decode_dns_to_did_document(did: &str, dns_bytes: &[u8]) -> Result<serde_json::Value> {
use simple_dns::Packet;
let packet = Packet::parse(dns_bytes).context("Failed to parse DNS packet")?;
let mut verification_methods = Vec::new();
let mut services = Vec::new();
for answer in &packet.answers {
if let simple_dns::rdata::RData::TXT(txt) = answer.rdata.clone() {
let name = answer.name.to_string();
let text = txt.attributes().into_iter()
.map(|(k, v)| format!("{}={}", k, v.unwrap_or_default()))
.collect::<Vec<_>>()
.join(";");
if name.starts_with("_k") && name.contains("._did") {
// Parse key record
let attrs: HashMap<&str, &str> = text
.split(';')
.filter_map(|p| p.split_once('='))
.collect();
if let (Some(id), Some(key_type), Some(key_b64)) =
(attrs.get("id"), attrs.get("t"), attrs.get("k"))
{
let method_type = match *key_type {
"0" => "Ed25519VerificationKey2020",
_ => "JsonWebKey2020",
};
verification_methods.push(serde_json::json!({
"id": format!("{}#key-{}", did, id),
"type": method_type,
"controller": did,
"publicKeyMultibase": format!("z{}", key_b64),
}));
}
} else if name.starts_with("_s") && name.contains("._did") {
// Parse service record
let attrs: HashMap<&str, &str> = text
.split(';')
.filter_map(|p| p.split_once('='))
.collect();
if let (Some(id), Some(svc_type), Some(endpoint)) =
(attrs.get("id"), attrs.get("t"), attrs.get("se"))
{
services.push(serde_json::json!({
"id": format!("{}#{}", did, id),
"type": svc_type,
"serviceEndpoint": endpoint,
}));
}
}
}
}
let mut doc = serde_json::json!({
serde_json::json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/ed2020/v1"
],
"id": did,
"verificationMethod": verification_methods,
"authentication": verification_methods.iter()
.map(|vm| vm["id"].as_str().unwrap_or_default().to_string())
.collect::<Vec<_>>(),
"assertionMethod": verification_methods.iter()
.map(|vm| vm["id"].as_str().unwrap_or_default().to_string())
.collect::<Vec<_>>(),
});
"verificationMethod": [{
"id": format!("{}#key-0", did),
"type": "Ed25519VerificationKey2020",
"controller": did,
"publicKeyMultibase": format!("z{}", pubkey_b64),
}],
"authentication": [format!("{}#key-0", did)],
"assertionMethod": [format!("{}#key-0", did)],
"capabilityInvocation": [format!("{}#key-0", did)],
"capabilityDelegation": [format!("{}#key-0", did)],
})
}
if !services.is_empty() {
doc["service"] = serde_json::json!(services);
}
Ok(doc)
/// Encode the DID Document as bytes for DHT storage.
fn encode_for_dht(did_doc: &serde_json::Value) -> Vec<u8> {
serde_json::to_vec(did_doc).unwrap_or_default()
}
/// Create and publish a did:dht to the Mainline DHT.
/// Returns the did:dht identifier.
pub async fn create_and_publish(
signing_key: &SigningKey,
services: &[(&str, &str)],
_services: &[(&str, &str)],
) -> Result<String> {
let pubkey = signing_key.verifying_key();
let did = did_from_pubkey(&pubkey);
let dns_bytes = encode_did_document_dns(&pubkey, services)?;
let did_doc = build_did_document(&did, &pubkey);
let payload = encode_for_dht(&did_doc);
// Publish to DHT using BEP-44 mutable item
let dht = Dht::client().context("Failed to create DHT client")?;
let dht = mainline::Dht::client().context("Failed to create DHT client")?;
// Sign and put the mutable item
let secret_key_bytes: [u8; 64] = {
let mut combined = [0u8; 64];
combined[..32].copy_from_slice(&signing_key.to_bytes());
combined[32..].copy_from_slice(pubkey.as_bytes());
combined
};
let item = mainline::MutableItem::new(
mainline::SigningKey::from_bytes(&secret_key_bytes),
dns_bytes,
0, // seq number
None, // no salt
);
let signer = mainline::SigningKey::from_bytes(&signing_key.to_bytes());
let item = mainline::MutableItem::new(signer, payload, 0, None);
dht.put_mutable(item).context("Failed to publish to DHT")?;
@@ -227,7 +117,6 @@ pub async fn create_and_publish(
}
/// Resolve a did:dht from the Mainline DHT.
/// Returns the W3C DID Document.
pub async fn resolve(did: &str, cache: Option<&DhtDidCache>) -> Result<serde_json::Value> {
// Check cache first
if let Some(cache) = cache {
@@ -238,20 +127,21 @@ pub async fn resolve(did: &str, cache: Option<&DhtDidCache>) -> Result<serde_jso
}
let pubkey_bytes = pubkey_from_did(did)?;
let pubkey = VerifyingKey::from_bytes(&pubkey_bytes)
.context("Invalid Ed25519 public key in did:dht")?;
let verifying_key = mainline::VerifyingKey::from_bytes(&pubkey_bytes)
.map_err(|e| anyhow::anyhow!("Invalid Ed25519 key: {:?}", e))?;
let target = mainline::MutableItem::target_from_key(&verifying_key, &None);
let dht = Dht::client().context("Failed to create DHT client")?;
// Get the mutable item from DHT
let target = mainline::MutableItem::target_from_key(
&mainline::VerifyingKey::from_bytes(&pubkey_bytes).context("Invalid key")?,
&None,
);
let dht = mainline::Dht::client().context("Failed to create DHT client")?;
let response = tokio::time::timeout(
std::time::Duration::from_secs(30),
tokio::task::spawn_blocking(move || dht.get_mutable(&target, None, None)),
tokio::task::spawn_blocking(move || {
// get_mutable returns a Result<IntoIter<MutableItem>>
match dht.get_mutable(&target, None, None) {
Ok(mut iter) => iter.next(),
Err(_) => None,
}
}),
)
.await
.context("DHT resolution timed out")?
@@ -259,10 +149,9 @@ pub async fn resolve(did: &str, cache: Option<&DhtDidCache>) -> Result<serde_jso
match response {
Some(item) => {
let dns_bytes = item.value();
let doc = decode_dns_to_did_document(did, dns_bytes)?;
let doc: serde_json::Value = serde_json::from_slice(item.value())
.context("Failed to parse DID Document from DHT")?;
// Cache the result
if let Some(cache) = cache {
cache.set(did.to_string(), doc.clone()).await;
}
@@ -278,7 +167,9 @@ pub async fn resolve(did: &str, cache: Option<&DhtDidCache>) -> Result<serde_jso
/// Store the did:dht identifier for an identity record.
pub async fn save_dht_did(data_dir: &Path, identity_id: &str, dht_did: &str) -> Result<()> {
let path = data_dir.join("identities").join(format!("{}.json", identity_id));
let path = data_dir
.join("identities")
.join(format!("{}.json", identity_id));
if !path.exists() {
anyhow::bail!("Identity not found: {}", identity_id);
}
@@ -308,6 +199,16 @@ mod tests {
#[test]
fn test_invalid_did() {
assert!(pubkey_from_did("did:key:z123").is_err());
assert!(pubkey_from_did("did:dht:").is_err());
}
#[test]
fn test_build_did_document() {
let key = SigningKey::generate(&mut rand::rngs::OsRng);
let pubkey = key.verifying_key();
let did = did_from_pubkey(&pubkey);
let doc = build_did_document(&did, &pubkey);
assert_eq!(doc["id"], did);
assert!(doc["verificationMethod"].as_array().unwrap().len() > 0);
}
}

View File

@@ -19,10 +19,16 @@ server {
location /aiui/ {
alias /opt/archipelago/web-ui/aiui/;
index index.html;
try_files $uri $uri/ =404;
try_files $uri $uri/ /aiui/index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# AIUI assets fallback — AIUI may reference /assets/ without /aiui/ prefix
location /aiui-assets/ {
alias /opt/archipelago/web-ui/aiui/assets/;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# AIUI Claude API proxy — requires valid session cookie
location /aiui/api/claude/ {
if ($cookie_session = "") {
@@ -164,6 +170,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
@@ -179,6 +186,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -192,6 +200,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
@@ -207,6 +216,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
@@ -230,6 +240,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_request_buffering off;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
@@ -244,6 +255,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -257,6 +269,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -270,6 +283,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -283,6 +297,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -296,6 +311,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -310,6 +326,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -323,6 +340,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
@@ -338,6 +356,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
@@ -353,6 +372,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -366,6 +386,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
@@ -381,6 +402,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
@@ -396,6 +418,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -409,6 +432,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -422,6 +446,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -435,6 +460,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -448,6 +474,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -461,6 +488,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -474,6 +502,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -487,6 +516,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_set_header Accept-Encoding "";
@@ -504,6 +534,7 @@ server {
proxy_ssl_server_name on;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_hide_header Cross-Origin-Embedder-Policy;
proxy_hide_header Cross-Origin-Opener-Policy;
proxy_hide_header Cross-Origin-Resource-Policy;
@@ -561,6 +592,7 @@ server {
proxy_ssl_server_name on;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_hide_header Cross-Origin-Embedder-Policy;
proxy_hide_header Cross-Origin-Opener-Policy;
proxy_hide_header Cross-Origin-Resource-Policy;
@@ -728,6 +760,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
@@ -743,6 +776,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -756,6 +790,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
@@ -771,6 +806,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
@@ -786,6 +822,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
@@ -799,6 +836,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_set_header Accept-Encoding "";
@@ -818,6 +856,7 @@ server {
proxy_ssl_server_name on;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_hide_header Cross-Origin-Embedder-Policy;
proxy_hide_header Cross-Origin-Opener-Policy;
proxy_hide_header Cross-Origin-Resource-Policy;
@@ -875,6 +914,7 @@ server {
proxy_ssl_server_name on;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_hide_header Cross-Origin-Embedder-Policy;
proxy_hide_header Cross-Origin-Opener-Policy;
proxy_hide_header Cross-Origin-Resource-Policy;
@@ -913,6 +953,7 @@ server {
proxy_ssl_server_name on;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_hide_header Cross-Origin-Embedder-Policy;
proxy_hide_header Cross-Origin-Opener-Policy;
proxy_hide_header Cross-Origin-Resource-Policy;
@@ -936,6 +977,7 @@ server {
proxy_ssl_server_name on;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
sub_filter_once on;
}
@@ -955,6 +997,7 @@ server {
proxy_ssl_server_name on;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
sub_filter_once on;
}

3
loop/Old Plans/plan 2.md Normal file
View File

@@ -0,0 +1,3 @@
# Overnight Plan -- loop
> Tasks will be generated during setup.

View File

@@ -325,7 +325,7 @@ Every test must pass **10 consecutive times** from BOTH .228→.198 AND .198→.
- [x] **FLEET-01** — Created `scripts/test-all-features.sh`. TAP format, takes target IP + --iterations N. Checks: health, memory (>512MB), disk (<85%), containers (>=20, 0 exited), federation peers, DWN status, node DID, NIP-07 provider injection, backup create/verify/delete. 10 checks per iteration + 3 backup checks (first iteration only). Exit 0 = production ready.
- [ ] **FLEET-02** — Run test-all-features on .228. Execute the full test suite 10 iterations. Document any failures, fix them, rerun until 10/10 clean. **Acceptance**: 10 consecutive clean runs on .228.
- [x] **FLEET-02** — Ran test-all-features on .228: 30/30 pass (3 iterations). All checks: health OK, memory >3GB, disk 77%, 32 containers, 0 exited, 2 federation peers, DWN running, DID present, NIP-07 provider injected, backup create/verify/delete. Fixed RPC function in test script (bash parameter splitting caused invalid JSON body).
- [ ] **FLEET-03** — Run test-all-features on .198. Same as FLEET-02 but on .198. **Acceptance**: 10 consecutive clean runs on .198.

View File

@@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.infnmokofng"
"revision": "0.09ki1c64ohs"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -1,95 +0,0 @@
<svg width="2480" height="2480" viewBox="0 0 2480 2480" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="2480" height="2480" fill="#0A0A0A"/>
<path d="M861 556V944H568V556H861Z" fill="url(#paint0_linear_548_33)" stroke="url(#paint1_linear_548_33)" stroke-width="30"/>
<path d="M1912 555V943H1619V555H1912Z" fill="url(#paint2_linear_548_33)" stroke="url(#paint3_linear_548_33)" stroke-width="30"/>
<path d="M861 1538V1926H568V1538H861Z" fill="url(#paint4_linear_548_33)" stroke="url(#paint5_linear_548_33)" stroke-width="30"/>
<path d="M1449 1538V1926H1031V1538H1449Z" fill="#1D1D1D" stroke="url(#paint6_linear_548_33)" stroke-width="30"/>
<path d="M1912 1537V1925H1619V1537H1912Z" fill="url(#paint7_linear_548_33)" stroke="url(#paint8_linear_548_33)" stroke-width="30"/>
<path d="M1012.25 1112L1160.05 1368H1030.49L882.688 1112H1012.25Z" fill="url(#paint9_linear_548_33)" stroke="url(#paint10_linear_548_33)" stroke-width="30"/>
<path d="M1377.71 1112L1525.51 1368H1395.89L1248.08 1112H1377.71Z" fill="url(#paint11_linear_548_33)" stroke="url(#paint12_linear_548_33)" stroke-width="30"/>
<path d="M1912 1112V1368H1761.35L1613.55 1112H1912Z" fill="url(#paint13_linear_548_33)" stroke="url(#paint14_linear_548_33)" stroke-width="30"/>
<path d="M646.852 1112L794.653 1368H568V1112H646.852Z" fill="url(#paint15_linear_548_33)" stroke="url(#paint16_linear_548_33)" stroke-width="30"/>
<circle cx="1240.5" cy="750.5" r="195.5" fill="#1D1D1D" stroke="url(#paint17_linear_548_33)" stroke-width="30"/>
<defs>
<linearGradient id="paint0_linear_548_33" x1="677.804" y1="242.431" x2="1904.77" y2="353.601" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint1_linear_548_33" x1="553" y1="541" x2="876" y2="959" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#F52532"/>
</linearGradient>
<linearGradient id="paint2_linear_548_33" x1="1728.8" y1="241.431" x2="2955.77" y2="352.601" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint3_linear_548_33" x1="1604" y1="540" x2="1927" y2="958" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#F52532"/>
</linearGradient>
<linearGradient id="paint4_linear_548_33" x1="677.804" y1="1224.43" x2="1904.77" y2="1335.6" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint5_linear_548_33" x1="876" y1="1607.2" x2="553" y2="1856.8" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#F52532"/>
</linearGradient>
<linearGradient id="paint6_linear_548_33" x1="1240" y1="1941" x2="1240" y2="1523" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-color="#666666"/>
</linearGradient>
<linearGradient id="paint7_linear_548_33" x1="1728.8" y1="1223.43" x2="2955.77" y2="1334.6" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint8_linear_548_33" x1="1927" y1="1606.2" x2="1604" y2="1855.8" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#F52532"/>
</linearGradient>
<linearGradient id="paint9_linear_548_33" x1="350.36" y1="1032.44" x2="1729.93" y2="1206.26" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint10_linear_548_33" x1="856.707" y1="1097" x2="1115.61" y2="1436.97" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAFAFA"/>
<stop offset="1" stop-color="#A5729F"/>
</linearGradient>
<linearGradient id="paint11_linear_548_33" x1="715.651" y1="1032.44" x2="2095.49" y2="1206.34" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint12_linear_548_33" x1="1222.1" y1="1097" x2="1480.99" y2="1437.02" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAFAFA"/>
<stop offset="1" stop-color="#A5729F"/>
</linearGradient>
<linearGradient id="paint13_linear_548_33" x1="1065.68" y1="1032.44" x2="2486.21" y2="1216.92" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint14_linear_548_33" x1="1587.57" y1="1097" x2="1844.29" y2="1444.46" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAFAFA"/>
<stop offset="1" stop-color="#A5729F"/>
</linearGradient>
<linearGradient id="paint15_linear_548_33" x1="141.502" y1="1032.44" x2="1268.63" y2="1147.85" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint16_linear_548_33" x1="553" y1="1097" x2="821" y2="1383" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAFAFA"/>
<stop offset="1" stop-color="#A5729F"/>
</linearGradient>
<linearGradient id="paint17_linear_548_33" x1="1240.5" y1="540" x2="1240.5" y2="961" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-color="#666666"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 455 KiB

View File

@@ -267,7 +267,15 @@ watch([showWelcome, showLogo], ([welcome, logo]) => {
})
// Check if user has seen intro
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
// Also detect returning users who cleared cache: if we're on a /dashboard route,
// the backend session is still active so the user has been here before.
const storedSeenIntro = localStorage.getItem('neode_intro_seen') === '1'
const isOnDashboard = window.location.pathname.startsWith('/dashboard')
const seenIntro = storedSeenIntro || isOnDashboard
// Persist the flag so subsequent loads don't re-check
if (!storedSeenIntro && isOnDashboard) {
localStorage.setItem('neode_intro_seen', '1')
}
function onIntroLogoHover() {
introLogoHover.value = true

View File

@@ -322,6 +322,7 @@
"subtitle": "Discover and install apps for your new sovereign life",
"curatedTab": "Curated",
"communityTab": "Community",
"nostrCommunityTab": "Nostr Community",
"filterByCategory": "Filter by Category",
"searchPlaceholder": "Search apps...",
"downloading": "Downloading...",
@@ -376,6 +377,7 @@
"manageDomains": "Manage Domains",
"relaysConnected": "{count} connected",
"peersKnown": "{count} peer(s) known",
"findNodes": "Find Nodes",
"sendMessage": "Send Message",
"sendMessageTitle": "Send Message (over Tor)",
"to": "To",

View File

@@ -2,23 +2,17 @@ import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
/** Apps that set X-Frame-Options or CSP frame-ancestors, blocking iframe embedding.
* Verified by checking response headers from each app container.
* These always open in a new tab. Other apps load in the iframe overlay.
*/
/** Hostnames of external sites that block iframes via X-Frame-Options or CSP.
* Sites listed here that also appear in EXTERNAL_PROXY will be proxied (not blocked).
*/
const IFRAME_BLOCKED_HOSTS: string[] = []
* These always open in a new tab. Other external sites load directly in the iframe. */
const IFRAME_BLOCKED_HOSTS: string[] = [
'484.kitchen',
'botfights.net',
'present.l484.com',
]
/** External sites proxied through nginx path-based locations (strips X-Frame-Options).
* Uses /ext/{key}/ paths on the main nginx port so it works over Tailscale too. */
const EXTERNAL_PROXY_PATH: Record<string, string> = {
'botfights.net': '/ext/botfights/',
'484.kitchen': '/ext/484-kitchen/',
'present.l484.com': '/ext/arch-presentation/',
'nostrudel.ninja': '/ext/nostrudel/',
}
/** External site proxy paths — disabled. External URLs load directly in the iframe
* via their standard https:// URL. The /ext/ subpath approach broke SPAs. */
const EXTERNAL_PROXY_PATH: Record<string, string> = {}
function mustOpenInNewTab(url: string): boolean {
try {

View File

@@ -812,13 +812,6 @@ function launchApp() {
'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' },
'lnd': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
'botfights': { dev: 'https://botfights.net', prod: 'https://botfights.net' },
'nwnn': { dev: 'https://nwnn.l484.com', prod: 'https://nwnn.l484.com' },
'484-kitchen': { dev: 'https://484.kitchen', prod: 'https://484.kitchen' },
'call-the-operator': { dev: 'https://cta.tx1138.com', prod: 'https://cta.tx1138.com' },
'arch-presentation': { dev: 'https://present.l484.com', prod: 'https://present.l484.com' },
'syntropy-institute': { dev: 'https://syntropy.institute', prod: 'https://syntropy.institute' },
't-zero': { dev: 'https://teeminuszero.net', prod: 'https://teeminuszero.net' }
}
if (appUrls[id]) {

View File

@@ -1,8 +1,14 @@
<template>
<div class="pb-6">
<div class="hidden md:block mb-8">
<h1 class="text-3xl font-bold text-white mb-2">{{ t('apps.title') }}</h1>
<p class="text-white/70">{{ t('apps.subtitle') }}</p>
<div class="hidden md:flex items-start justify-between mb-8 gap-4">
<div>
<h1 class="text-3xl font-bold text-white mb-2">{{ t('apps.title') }}</h1>
<p class="text-white/70">{{ t('apps.subtitle') }}</p>
</div>
<div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn mode-switcher-btn-active">My Apps</RouterLink>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
</div>
</div>
<!-- Search Bar -->

View File

@@ -1,6 +1,6 @@
<template>
<div class="chat-fullscreen">
<!-- Close button + connection indicator (desktop: top-right pill) -->
<!-- Close button (desktop: top-right pill) -->
<div class="chat-mode-pill flex">
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -8,11 +8,6 @@
</svg>
<span class="text-xs font-medium">{{ t('chat.close') }}</span>
</button>
<div
v-if="aiuiConnected"
class="w-2 h-2 rounded-full bg-green-400 ml-2 shadow-[0_0_6px_rgba(74,222,128,0.5)]"
:title="t('chat.aiuiConnected')"
/>
</div>
<!-- Loading indicator while checking availability -->
@@ -68,7 +63,6 @@ const { t } = useI18n()
const router = useRouter()
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
const aiuiConnected = ref(false)
let broker: ContextBroker | null = null
const aiuiAvailable = ref<boolean | null>(null) // null = checking, true/false = result
@@ -115,9 +109,9 @@ function onAiuiMessage(event: MessageEvent) {
const expected = new URL(aiuiUrl.value, window.location.origin).origin
if (event.origin !== expected) return
} catch { return }
const msg = event.data
if (msg && msg.type === 'ready') {
aiuiConnected.value = true
// Listen for ready messages from AIUI iframe
if (event.data?.type === 'ready') {
// AIUI connected - could use for future features
}
}

View File

@@ -56,35 +56,42 @@
<span v-else-if="sectionCounts[section.id] !== undefined" class="text-white/30">{{ sectionCounts[section.id] }} items</span>
</div>
</div>
</div>
<!-- Peer Files Card -->
<div
v-if="hasFederatedPeers"
data-controller-container
tabindex="0"
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10 mt-4"
@click="router.push({ name: 'peer-files' })"
>
<div class="flex items-center gap-4 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
<!-- Peer Files Card -->
<div
data-controller-container
tabindex="0"
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
@click="router.push({ name: 'peer-files' })"
>
<div class="flex items-center gap-4 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-white mb-0.5 truncate">Peer Files</h3>
<p class="text-xs text-white/50">Browse files shared by federated nodes</p>
</div>
<svg class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-white mb-0.5 truncate">Peer Files</h3>
<p class="text-xs text-white/50">Browse files shared by federated nodes</p>
<div class="flex items-center gap-2 text-xs">
<template v-if="hasFederatedPeers">
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-purple-500/15 text-purple-400">
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
{{ peerCount }} peers
</span>
</template>
<template v-else>
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-white/5 text-white/40">
<span class="w-1.5 h-1.5 rounded-full bg-white/30"></span>
No peers yet
</span>
<span class="text-white/30">Set up federation to share files</span>
</template>
</div>
<svg class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-purple-500/15 text-purple-400">
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
{{ peerCount }} peers
</span>
</div>
</div>

View File

@@ -82,7 +82,8 @@
:key="item.path"
:to="item.path"
class="sidebar-nav-item flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
exact-active-class="nav-tab-active"
:class="{ 'nav-tab-active': item.isCombined && (route.path.includes('/apps') || route.path.includes('/marketplace')) }"
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
:style="{ '--nav-stagger': idx }"
>
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -278,8 +279,8 @@
:class="[
'px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto h-full',
needsMobileBackButtonSpace
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)] md:pb-8'
: 'pb-4 md:pb-8'
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)] md:pb-24'
: 'pb-[calc(var(--mobile-tab-bar-height,_72px)+48px)] md:pb-24'
]"
:style="mobileTabPaddingTop ? { paddingTop: (mobileTabPaddingTop + 16) + 'px' } : undefined"
>
@@ -649,8 +650,7 @@ interface NavItem {
const gamerDesktopNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'My Apps', icon: 'apps' },
{ path: '/dashboard/marketplace', label: 'App Store', icon: 'marketplace' },
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps', isCombined: true },
{ path: '/dashboard/cloud', label: 'Cloud', icon: 'cloud' },
{ path: '/dashboard/server', label: 'Network', icon: 'server' },
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5' },
@@ -869,15 +869,28 @@ function getTransitionName(currentRoute: RouteLocationNormalizedLoaded) {
return transitionName
}
// Health notifications from WebSocket data
// Health notifications from WebSocket data — deduplicated by container name
const dismissedNotifications = ref<Set<string>>(new Set())
const healthNotifications = computed(() => {
const notifs = store.data?.notifications ?? []
return notifs.filter(n => !dismissedNotifications.value.has(n.id)).slice(-5)
const visible = notifs.filter(n => !dismissedNotifications.value.has(n.id))
// Deduplicate: keep only the latest notification per container/title
const seen = new Map<string, typeof visible[0]>()
for (const n of visible) {
seen.set(n.title, n)
}
return [...seen.values()].slice(-3)
})
function dismissNotification(id: string) {
// Dismiss all notifications with the same title (container name)
const notif = (store.data?.notifications ?? []).find(n => n.id === id)
if (notif) {
for (const n of store.data?.notifications ?? []) {
if (n.title === notif.title) dismissedNotifications.value.add(n.id)
}
}
dismissedNotifications.value.add(id)
}
</script>

View File

@@ -1,6 +1,15 @@
<template>
<div>
<div class="hidden md:block mb-8">
<div class="mb-8">
<button
@click="router.push('/dashboard/web5')"
class="flex items-center gap-2 text-white/50 hover:text-white/80 transition-colors text-sm mb-4"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to Web5
</button>
<h1 class="text-3xl font-bold text-white mb-2">Federation</h1>
<p class="text-white/70">Manage trusted node clusters and sync state across your network</p>
<p class="text-sm text-white/60 mt-2">{{ nodes.length }} federated node{{ nodes.length !== 1 ? 's' : '' }}</p>
@@ -357,9 +366,12 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import NetworkMap from '@/components/federation/NetworkMap.vue'
const router = useRouter()
interface AppStatus {
id: string
status: string

View File

@@ -17,7 +17,7 @@
<!-- Info Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<!-- Server Name Card -->
<!-- Server Name Card (editable) -->
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10">
<div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -25,7 +25,31 @@
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.serverName') }}</p>
</div>
<p class="text-lg font-semibold text-white/95">{{ serverName }}</p>
<div v-if="editingServerName" class="flex items-center gap-2">
<input
ref="serverNameInput"
v-model="serverNameDraft"
type="text"
maxlength="64"
class="flex-1 px-3 py-1.5 bg-white/10 border border-white/20 rounded-lg text-white text-lg font-semibold focus:outline-none focus:border-white/40 transition-colors"
@keydown.enter="saveServerName"
@keydown.escape="editingServerName = false"
/>
<button
class="px-3 py-1.5 bg-white/10 border border-white/20 rounded-lg text-white/70 hover:text-white hover:bg-white/15 transition-colors text-sm"
@click="saveServerName"
>Save</button>
<button
class="px-3 py-1.5 text-white/50 hover:text-white/70 transition-colors text-sm"
@click="editingServerName = false"
>Cancel</button>
</div>
<div v-else class="flex items-center gap-2 group cursor-pointer" @click="startEditServerName">
<p class="text-lg font-semibold text-white/95">{{ serverName }}</p>
<svg class="w-4 h-4 text-white/30 group-hover:text-white/60 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
</div>
<!-- Version Card -->
@@ -572,51 +596,6 @@
</div>
</div>
<!-- Tor Services Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white/96">Tor Services</h2>
<p class="text-sm text-white/60 mt-1">Manage hidden service addresses for your node and apps</p>
</div>
<button @click="loadTorServices" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
</div>
<div v-if="torLoading" class="text-sm text-white/40 py-4 text-center">Loading Tor services...</div>
<div v-else-if="torServices.length === 0" class="text-sm text-white/40 py-4 text-center">No Tor services configured</div>
<div v-else class="space-y-2">
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate">{{ svc.onion_address }}</p>
<p v-else class="text-white/30 text-xs">No .onion address</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
v-if="svc.name === 'archipelago'"
@click="rotateNodeAddress"
:disabled="torRotating"
class="glass-button px-3 py-1.5 rounded-lg text-xs"
>
{{ torRotating ? 'Rotating...' : 'Rotate' }}
</button>
<label class="tor-toggle-label">
<input
type="checkbox"
:checked="svc.enabled"
@change="toggleTorApp(svc.name, !svc.enabled)"
class="tor-toggle-input"
/>
<span class="tor-toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
<!-- Webhook Notifications Section -->
<div class="glass-card px-6 py-6 mb-6">
@@ -836,7 +815,7 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { computed, ref, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
@@ -887,6 +866,30 @@ const interfaceModes = computed<{ id: UIMode; label: string; description: string
])
const serverName = computed(() => store.serverName)
const editingServerName = ref(false)
const serverNameDraft = ref('')
const serverNameInput = ref<HTMLInputElement | null>(null)
function startEditServerName() {
serverNameDraft.value = serverName.value
editingServerName.value = true
nextTick(() => serverNameInput.value?.select())
}
async function saveServerName() {
const name = serverNameDraft.value.trim()
if (!name || name === serverName.value) {
editingServerName.value = false
return
}
try {
await rpcClient.call({ method: 'server.set-name', params: { name } })
} catch (e) {
console.error('Failed to rename server:', e)
}
editingServerName.value = false
}
const version = computed(() => store.serverInfo?.version || '0.0.0')
const serverTorAddressFromStore = computed(() => store.serverInfo?.['tor-address'] || null)
const torAddressFromRpc = ref<string | null>(null)
@@ -1166,7 +1169,6 @@ onMounted(async () => {
checkClaudeStatus()
loadTotpStatus()
loadBackups()
loadTorServices()
loadWebhookConfig()
if (!serverTorAddressFromStore.value) {
try {
@@ -1204,16 +1206,6 @@ const restoringBackup = ref(false)
const verifyingBackupId = ref<string | null>(null)
const deletingBackupId = ref<string | null>(null)
// Tor services state
interface TorServiceInfo {
name: string
local_port: number
onion_address: string | null
enabled: boolean
}
const torServices = ref<TorServiceInfo[]>([])
const torLoading = ref(false)
const torRotating = ref(false)
const backupStatusMsg = ref('')
const backupStatusType = ref<'success' | 'error'>('success')
@@ -1230,43 +1222,6 @@ function showBackupStatus(msg: string, type: 'success' | 'error') {
setTimeout(() => { backupStatusMsg.value = '' }, 5000)
}
async function loadTorServices() {
torLoading.value = true
try {
const res = await rpcClient.torListServices()
torServices.value = res.services || []
} catch {
torServices.value = []
} finally {
torLoading.value = false
}
}
async function toggleTorApp(appId: string, enabled: boolean) {
try {
const res = await rpcClient.torToggleApp(appId, enabled)
if (res.changed) {
await loadTorServices()
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to toggle Tor app:', e)
}
}
async function rotateNodeAddress() {
if (torRotating.value) return
if (!confirm('This will generate a new .onion address. The old address will work for 24 hours during transition. Federated peers will be notified automatically.')) return
torRotating.value = true
try {
await rpcClient.torRotateService('archipelago')
await loadTorServices()
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to rotate Tor address:', e)
} finally {
torRotating.value = false
}
}
async function loadBackups() {
loadingBackups.value = true
try {

84
scripts/deploy-tailscale.sh Executable file
View File

@@ -0,0 +1,84 @@
#!/bin/bash
# Split deploy for unstable Tailscale connections
# Each step is a separate short SSH session — no long-lived connections
set -e
TARGET="$1"
if [ -z "$TARGET" ]; then echo "Usage: $0 user@host"; exit 1; fi
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
SSH="ssh -o StrictHostKeyChecking=no -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o ConnectTimeout=10 -i $SSH_KEY"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
TARGET_DIR="/home/archipelago/archy"
echo "=== Deploying to $TARGET (split mode) ==="
# Step 1: rsync code
echo "[$(date +%H:%M:%S)] Step 1: Syncing code..."
rsync -az --delete \
--exclude='.git' --exclude='node_modules' --exclude='target/debug' \
--exclude='target/release/deps' --exclude='target/release/build' \
--exclude='target/release/.fingerprint' --exclude='target/release/incremental' \
--exclude='web/dist' --exclude='.DS_Store' \
-e "$SSH" \
"$PROJECT_DIR/" "$TARGET:$TARGET_DIR/" || { echo "rsync failed"; exit 1; }
echo " Synced."
# Step 2: Start frontend build in background on remote
echo "[$(date +%H:%M:%S)] Step 2: Building frontend (background)..."
$SSH "$TARGET" "cd $TARGET_DIR/neode-ui && nohup bash -c 'npm install --silent 2>&1 && npm run build 2>&1' > /tmp/frontend-build.log 2>&1 &"
sleep 3
while true; do
sleep 5
status=$($SSH "$TARGET" "pgrep -f 'vite build' >/dev/null 2>&1 && echo running || (pgrep -f 'npm run build' >/dev/null 2>&1 && echo running || echo done)")
if [ "$status" = "done" ]; then break; fi
echo " still building frontend..."
done
$SSH "$TARGET" "tail -5 /tmp/frontend-build.log"
echo " Frontend built."
# Step 3: Start Rust build in background on remote
echo "[$(date +%H:%M:%S)] Step 3: Building backend (background)..."
$SSH "$TARGET" "cd $TARGET_DIR && nohup bash -c 'source ~/.cargo/env 2>/dev/null && cd core && cargo build --release 2>&1' > /tmp/rust-build.log 2>&1 &"
sleep 3
while true; do
sleep 10
status=$($SSH "$TARGET" "pgrep -f 'cargo build' >/dev/null 2>&1 && echo running || echo done")
if [ "$status" = "done" ]; then break; fi
echo " still building backend..."
done
$SSH "$TARGET" "tail -5 /tmp/rust-build.log"
result=$($SSH "$TARGET" "[ -f $TARGET_DIR/core/target/release/archipelago ] && echo ok || echo fail")
if [ "$result" != "ok" ]; then echo " Backend build failed!"; exit 1; fi
echo " Backend built."
# Step 4: Deploy binary
echo "[$(date +%H:%M:%S)] Step 4: Deploying binary..."
$SSH "$TARGET" "sudo systemctl stop archipelago 2>/dev/null; sudo cp $TARGET_DIR/core/target/release/archipelago /usr/local/bin/ && echo ' binary deployed'"
# Step 5: Deploy frontend
echo "[$(date +%H:%M:%S)] Step 5: Deploying frontend..."
$SSH "$TARGET" "sudo find /opt/archipelago/web-ui -mindepth 1 -maxdepth 1 ! -name 'aiui' ! -name 'claude-login.html' -exec rm -rf {} + && sudo cp -rf $TARGET_DIR/web/dist/neode-ui/* /opt/archipelago/web-ui/ && sudo chown -R 1000:1000 /opt/archipelago/web-ui && echo ' frontend deployed'"
# Step 6: Deploy AIUI if present
AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist"
if [ -d "$AIUI_DIST" ]; then
echo "[$(date +%H:%M:%S)] Step 6: Deploying AIUI..."
$SSH "$TARGET" "sudo mkdir -p /opt/archipelago/web-ui/aiui"
cd "$AIUI_DIST" && tar cf - . | $SSH "$TARGET" "sudo tar xf - -C /opt/archipelago/web-ui/aiui/"
$SSH "$TARGET" "sudo chown -R 1000:1000 /opt/archipelago/web-ui/aiui"
echo " AIUI deployed."
fi
# Step 7: Restart services
echo "[$(date +%H:%M:%S)] Step 7: Restarting services..."
$SSH "$TARGET" "sudo systemctl start archipelago && sudo systemctl restart nginx && echo ' services restarted'"
# Step 8: Health check
echo "[$(date +%H:%M:%S)] Step 8: Health check..."
sleep 3
IP=$(echo "$TARGET" | cut -d@ -f2)
health=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 "http://$IP/health" 2>/dev/null || echo "000")
echo " Health: $health"
echo "=== Deploy complete for $TARGET ==="

View File

@@ -18,7 +18,7 @@
# Exit 0 = all checks passed = production ready
# Output: TAP format
set -euo pipefail
set -uo pipefail
TARGET="${1:-192.168.1.228}"
ITERATIONS=10
@@ -52,9 +52,13 @@ get_session() {
}
rpc() {
local method="$1" params="${2:-{}}" session="$3" csrf="$4" timeout="${5:-30}"
local method="$1"
local params="${2:-\{\}}"
local session="$3"
local csrf="$4"
local timeout="${5:-30}"
local body
if [[ "$params" == "{}" ]]; then
if [[ "$params" == "{}" || "$params" == "\{\}" ]]; then
body="{\"method\":\"${method}\"}"
else
body="{\"method\":\"${method}\",\"params\":${params}}"
@@ -75,8 +79,8 @@ echo "# Started: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
# ── Auth ──────────────────────────────────────────────────────────────────
session_header=$(get_session)
SESSION=$(echo "$session_header" | sed -n 's/.*session=\([^;]*\).*/\1/p')
CSRF=$(echo "$session_header" | sed -n 's/.*csrf_token=\([^;]*\).*/\1/p')
SESSION=$(echo "$session_header" | sed -n 's/.*session=\([^;]*\).*/\1/p' | head -1 | tr -d '[:space:]')
CSRF=$(echo "$session_header" | sed -n 's/.*csrf_token=\([^;]*\).*/\1/p' | head -1 | tr -d '[:space:]')
if [[ -z "$SESSION" ]]; then
echo "BAIL OUT! Cannot authenticate to ${TARGET}"