Refactor Indeehub integration and enhance deployment documentation

- Updated Indeehub references throughout the codebase, changing the name from "IndeedHub" to "Indeehub" for consistency.
- Implemented a virtual app structure for Indeehub, allowing it to open an external URL without requiring a container.
- Enhanced deployment scripts and documentation to clarify SSH access and password management for Indeehub.
- Improved error handling and retry logic in various components to ensure better user experience during onboarding and app interactions.
- Updated CSS for visual enhancements and added new buttons for improved navigation in the AppLauncherOverlay.
This commit is contained in:
Dorian 2026-03-01 17:53:18 +00:00
parent 2c15311ab6
commit 7a05e11834
34 changed files with 877 additions and 163 deletions

View File

@ -29,7 +29,8 @@ Do not leave deployment or testing to the user. The agent has SSH access to perf
### Backend: build on server via rsync (never on macOS)
- **Always** deploy backend by: (1) rsync `core/` to `archipelago@192.168.1.228:~/archy/core/`, then (2) SSH and run `cargo build --release` on the server, then copy binary to `/usr/local/bin/` and restart `archipelago.service`.
- Use `sshpass -p "archipelago"` for non-interactive rsync/SSH. **Action these builds** when making backend changes; do not build the Rust binary on macOS and copy it (causes Exec format error on Linux).
- Use `sshpass -p 'EwPDR8q45l0Upx@'` for non-interactive rsync/SSH. The password is stored in `scripts/deploy-config.sh` (gitignored) and sourced by the deploy script automatically.
- **Do not** build the Rust binary on macOS and copy it (causes Exec format error on Linux).
### Standard Deployment Command
@ -61,13 +62,16 @@ Deploys to 192.168.1.228 first (builds there), then copies binary and web-ui to
- **Web Server**: Nginx
- **Backend**: Systemd service (`archipelago.service`) running as root
## SSH Key Management
## SSH Access
The deployment scripts require SSH key authentication. If you encounter `Permission denied` errors:
**Current credentials**: `archipelago@192.168.1.228` with password `EwPDR8q45l0Upx@`
1. Ensure SSH key is loaded: `ssh-add -l`
2. Add key if needed: `ssh-add ~/.ssh/id_ed25519`
3. Enter passphrase when prompted
The deploy script sources `scripts/deploy-config.sh` (gitignored) which sets `ARCHIPELAGO_PASSWORD`. For manual SSH/rsync commands, use:
```bash
sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228
```
If `sshpass` hangs, SSH may be rate-limited from too many connections. Wait 10-15 seconds and retry.
## Development Paths
@ -136,7 +140,7 @@ Common containers:
### Debug a Fresh ISO Install
1. **Flash** the ISO to a test machine (e.g. 192.168.1.198)
2. **SSH** after first boot (same user/password as dev: `archipelago`/`archipelago`):
2. **SSH** after first boot (default ISO password is `archipelago`, dev server uses `EwPDR8q45l0Upx@`):
```bash
ssh-keygen -R 192.168.1.198 # if host key changed after reflash
sshpass -p "archipelago" ssh -o StrictHostKeyChecking=no archipelago@192.168.1.198

44
apps/indeedhub/README.md Normal file
View File

@ -0,0 +1,44 @@
# IndeedHub (Indeehub Prototype)
Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.
## Building the Image
The app image is built from the **Indeehub Prototype** project. The prototype lives at `../../Indeedhub Prototype` (relative to the archy repo).
### Option 1: Build from prototype directory
```bash
cd "/path/to/Indeedhub Prototype"
podman build -t localhost/indeedhub:latest .
```
### Option 2: Use the build script
```bash
# From archy repo root
./apps/indeedhub/build-from-prototype.sh
```
### Option 3: Full deploy (build + run on server)
```bash
cd "/path/to/Indeedhub Prototype"
./deploy-to-archipelago.sh
```
## Installing from My Apps
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
## Port
- Web UI: 7777
## Container
- Image: `localhost/indeedhub:latest` (built locally, not pulled from a registry)
- Port: 7777

View File

@ -0,0 +1,30 @@
#!/bin/bash
# Build Indeehub image from the Indeehub Prototype project
# Usage: ./build-from-prototype.sh [path-to-prototype]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_PROTOTYPE="$SCRIPT_DIR/../../Indeedhub Prototype"
PROTOTYPE_DIR="${1:-$DEFAULT_PROTOTYPE}"
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"
exit 1
fi
# Determine container runtime
RUNTIME="podman"
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 "✅ Built $IMAGE_TAG"
echo ""
echo "You can now install Indeehub from the App Store in Archipelago."

View File

@ -1,6 +1,6 @@
app:
id: indeedhub
name: 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.
category: media
@ -59,7 +59,7 @@ app:
path: /
metadata:
author: IndeedHub Team
author: Indeehub Team
website: https://indeedhub.com
source: https://github.com/indeedhub/indeedhub
license: MIT

View File

@ -618,6 +618,11 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid Docker image format"));
}
// Virtual app: Indeehub (no container, opens external URL)
if package_id == "indeedhub" {
return Ok(serde_json::json!({ "success": true }));
}
// Multi-container apps: create full stack
if package_id == "immich" {
return self.install_immich_stack().await;
@ -637,7 +642,9 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Container {} already exists. Stop and remove it first.", package_id));
}
// Pull the image (with verification in the future)
// Pull the image (skip for local images - must be built locally first)
let is_local_image = docker_image.starts_with("localhost/");
if !is_local_image {
debug!("Pulling image: {}", docker_image);
let pull_output = tokio::process::Command::new("sudo")
.args(["podman", "pull", docker_image])
@ -649,6 +656,21 @@ impl RpcHandler {
let stderr = String::from_utf8_lossy(&pull_output.stderr);
return Err(anyhow::anyhow!("Failed to pull image: {}", stderr));
}
} else {
// Verify local image exists
let images_output = tokio::process::Command::new("sudo")
.args(["podman", "images", "-q", docker_image])
.output()
.await
.context("Failed to check local image")?;
if String::from_utf8_lossy(&images_output.stdout).trim().is_empty() {
return Err(anyhow::anyhow!(
"Local image {} not found. Run ./deploy-to-archipelago.sh from the Indeehub Prototype project on your Mac—it builds the image on this server and starts the app.",
docker_image
));
}
debug!("Using local image: {}", docker_image);
}
// Create and start container with security constraints
// TODO: Load these from manifest.yml for the specific app
@ -1708,6 +1730,13 @@ fn get_app_config(
None,
None,
),
"indeedhub" => (
vec!["7777:7777".to_string()],
vec![],
vec!["NGINX_HOST=0.0.0.0".to_string(), "NGINX_PORT=7777".to_string()],
None,
None,
),
_ => (vec![], vec![], vec![], None, None), // No default config, user must configure manually
}
}

View File

@ -202,6 +202,65 @@ impl DockerPackageScanner {
info!("Detected container: {} ({})", metadata.title, package_state_str(&package_state));
}
// Virtual app: Indeehub (opens external URL, no container required)
if !packages.contains_key("indeedhub") {
let metadata = get_app_metadata("indeedhub");
let lan_address = Some("https://archipelago.indeehub.studio".to_string());
let virtual_pkg = PackageDataEntry {
state: PackageState::Running,
static_files: StaticFiles {
license: "MIT".to_string(),
instructions: metadata.description.clone(),
icon: metadata.icon.clone(),
},
manifest: Manifest {
id: "indeedhub".to_string(),
title: metadata.title.clone(),
version: "0.1.0".to_string(),
description: Description {
short: metadata.description.clone(),
long: metadata.description.clone(),
},
release_notes: "Virtual app (opens archipelago.indeehub.studio)".to_string(),
license: "MIT".to_string(),
wrapper_repo: metadata.repo.clone(),
upstream_repo: metadata.repo.clone(),
support_site: metadata.repo.clone(),
marketing_site: metadata.repo.clone(),
donation_url: None,
author: Some("Indeehub Team".to_string()),
website: lan_address.clone(),
interfaces: Some(Interfaces {
main: Some(MainInterface {
ui: Some("true".to_string()),
tor_config: None,
lan_config: None,
}),
}),
},
installed: Some(InstalledPackageDataEntry {
current_dependents: HashMap::new(),
current_dependencies: HashMap::new(),
last_backup: None,
interface_addresses: {
let mut addresses = HashMap::new();
addresses.insert(
"main".to_string(),
InterfaceAddress {
tor_address: String::new(),
lan_address: lan_address,
},
);
addresses
},
status: ServiceStatus::Running,
}),
install_progress: None,
};
packages.insert("indeedhub".to_string(), virtual_pkg);
info!("Virtual app: Indeehub (archipelago.indeehub.studio)");
}
Ok(packages)
}
}
@ -360,9 +419,9 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/tailscale/tailscale".to_string(),
},
"indeedhub" => AppMetadata {
title: "IndeedHub".to_string(),
title: "Indeehub".to_string(),
description: "Decentralized media streaming platform".to_string(),
icon: "/assets/img/app-icons/indeedhub.png".to_string(),
icon: "https://indeehub.studio/favicon.ico".to_string(),
repo: "https://github.com/indeedhub/indeedhub".to_string(),
},
_ => AppMetadata {

View File

@ -24,7 +24,9 @@ class RPCClient {
async call<T>(options: RPCOptions): Promise<T> {
const { method, params = {}, timeout = 30000 } = options
const maxRetries = 3
for (let attempt = 0; attempt < maxRetries; attempt++) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
@ -42,7 +44,13 @@ class RPCClient {
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
const isRetryable = response.status === 502 || response.status === 503
if (isRetryable && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
continue
}
throw err
}
const data: RPCResponse<T> = await response.json()
@ -56,7 +64,18 @@ class RPCClient {
clearTimeout(timeoutId)
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new Error('Request timeout')
const timeoutErr = new Error('Request timeout')
if (attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
continue
}
throw timeoutErr
}
const msg = error.message
const isRetryable = /502|503|Bad Gateway|fetch|network/i.test(msg)
if (isRetryable && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
continue
}
throw error
}
@ -64,6 +83,9 @@ class RPCClient {
}
}
throw new Error('Request failed after retries')
}
// Convenience methods
async login(password: string): Promise<void> {
return this.call({

View File

@ -352,7 +352,7 @@ function getWebSocketClient(): WebSocketClient {
if (typeof window !== 'undefined') {
(window as any).__archipelago_ws_client = wsClientInstance
}
console.log('[WebSocket] Created new client instance')
if (import.meta.env.DEV) console.debug('[WebSocket] Created new client instance')
}
return wsClientInstance

View File

@ -39,6 +39,17 @@
<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>
</button>
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors"
aria-label="Open in new tab"
title="Open in new tab"
@click="openInNewTab"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
<button
ref="closeBtnRef"
type="button"
@ -86,6 +97,12 @@ function refreshIframe() {
iframeRefreshKey.value++
}
function openInNewTab() {
if (store.url) {
window.open(store.url, '_blank', 'noopener,noreferrer')
}
}
function onIframeLoad() {
injectScrollbarHideIfSameOrigin()
isRefreshing.value = false

View File

@ -6,10 +6,18 @@ let audioContext: AudioContext | null = null
let introAudio: HTMLAudioElement | null = null
let introGain: GainNode | null = null
/** Get AudioContext - only returns existing. Create via resumeAudioContext() after user gesture. */
function getContext(): AudioContext | null {
return audioContext
}
/** Create AudioContext if needed (call only from user gesture - click/tap/key) */
function ensureContext(): AudioContext | null {
if (audioContext) return audioContext
try {
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
const Ctx = window.AudioContext || (window as any).webkitAudioContext
if (!Ctx) return null
audioContext = new Ctx()
return audioContext
} catch {
return null
@ -44,21 +52,21 @@ export function playLoopStart() {
.catch(() => {})
}
/** Resume audio context (call on first user interaction to unlock autoplay) */
/** Resume audio context - MUST be called from user gesture (click/tap/key). Creates context if needed. */
export function resumeAudioContext() {
const ctx = getContext()
const ctx = ensureContext()
if (ctx?.state === 'suspended') {
ctx.resume().catch(() => {})
}
}
/** Start intro loop - Cosmic Updrift (free royalty) */
/** Start intro loop - Cosmic Updrift. Only works after resumeAudioContext() (user gesture). */
export function startSynthwave() {
const ctxOrNull = getContext()
if (!ctxOrNull) return
try {
if (ctxOrNull.state === 'suspended') ctxOrNull.resume()
if (ctxOrNull.state === 'suspended') ctxOrNull.resume().catch(() => {})
} catch {
return
}

View File

@ -1,20 +1,30 @@
/**
* Onboarding state - prefers backend, falls back to localStorage for mock/offline.
* Hardened: retries on 502/503, never blocks completion.
*/
import { rpcClient } from '@/api/rpc-client'
export async function isOnboardingComplete(): Promise<boolean> {
async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T | null> {
for (let i = 0; i < maxRetries; i++) {
try {
return await rpcClient.isOnboardingComplete()
} catch {
return localStorage.getItem('neode_onboarding_complete') === '1'
return await fn()
} catch (e) {
const msg = e instanceof Error ? e.message : ''
const isRetryable = /502|503|timeout|fetch|network/i.test(msg)
if (!isRetryable || i === maxRetries - 1) return null
await new Promise((r) => setTimeout(r, 800 * (i + 1)))
}
}
return null
}
export async function isOnboardingComplete(): Promise<boolean> {
const result = await callWithRetry(() => rpcClient.isOnboardingComplete(), 2)
if (result !== null) return result
return localStorage.getItem('neode_onboarding_complete') === '1'
}
export async function completeOnboarding(): Promise<void> {
try {
await rpcClient.completeOnboarding()
} finally {
await callWithRetry(() => rpcClient.completeOnboarding(), 3)
localStorage.setItem('neode_onboarding_complete', '1')
}
}

View File

@ -134,8 +134,18 @@ router.beforeEach(async (to, _from, next) => {
// Allow all public routes (login, onboarding) without auth check
if (isPublic) {
// If already authenticated and trying to access login, redirect to home
// If authenticated and visiting /login, validate session first
if (to.path === '/login' && store.isAuthenticated) {
if (store.needsSessionValidation()) {
const valid = await store.checkSession()
if (valid) {
next({ name: 'home' })
return
}
// Session invalid, allow login page
next()
return
}
next({ name: 'home' })
return
}
@ -143,30 +153,35 @@ router.beforeEach(async (to, _from, next) => {
return
}
// Protected routes require authentication
// Check session if not already authenticated
// Protected routes: validate session if stale auth from localStorage
if (store.needsSessionValidation()) {
const valid = await store.checkSession()
if (!valid) {
next('/login')
return
}
next()
return
}
// Not authenticated at all
if (!store.isAuthenticated) {
const hasSession = await store.checkSession()
if (hasSession) {
next()
return
}
// No valid session - redirect to login
next('/login')
return
}
// User is already authenticated (from localStorage on page load)
// Make sure WebSocket is connected
// Validated and authenticated - ensure WebSocket is connected
if (!store.isConnected && !store.isReconnecting) {
console.log('[Router] User authenticated but WebSocket not connected, connecting...')
store.connectWebSocket().catch((err) => {
console.warn('[Router] WebSocket connection failed:', err)
})
}
// Authenticated user accessing protected route
next()
})

View File

@ -15,6 +15,7 @@ export const useAppStore = defineStore('app', () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
let isWsSubscribed = false
let sessionValidated = false
// Computed
const serverInfo = computed(() => data.value?.['server-info'])
@ -33,13 +34,16 @@ export const useAppStore = defineStore('app', () => {
try {
await rpcClient.login(password)
isAuthenticated.value = true
sessionValidated = true
localStorage.setItem('neode-auth', 'true')
// Connect WebSocket after successful login
await connectWebSocket()
// Initialize data
// Initialize data structure immediately so dashboard can render
await initializeData()
// Connect WebSocket in background - don't block login flow
connectWebSocket().catch((err) => {
console.warn('[Store] WebSocket connection failed after login, will retry:', err)
})
} catch (err) {
error.value = err instanceof Error ? err.message : 'Login failed'
throw err
@ -55,10 +59,10 @@ export const useAppStore = defineStore('app', () => {
console.error('Logout error:', err)
} finally {
isAuthenticated.value = false
sessionValidated = false
localStorage.removeItem('neode-auth')
data.value = null
isWsSubscribed = false
// Disconnect WebSocket on logout (this prevents reconnection)
wsClient.disconnect()
isConnected.value = false
isReconnecting.value = false
@ -182,48 +186,42 @@ export const useAppStore = defineStore('app', () => {
}
}
// Check session validity on app load
// Check session validity on app load or stale auth
async function checkSession(): Promise<boolean> {
console.log('[Store] Checking session...')
if (!localStorage.getItem('neode-auth')) {
console.log('[Store] No auth token found')
return false
}
try {
// Try to make an authenticated request to verify session
console.log('[Store] Validating session with backend...')
await rpcClient.call({ method: 'server.echo', params: { message: 'ping' } })
isAuthenticated.value = true
console.log('[Store] Session valid, reconnecting WebSocket...')
sessionValidated = true
// Initialize data structure first
await initializeData()
// Connect WebSocket - don't wait for it, let it reconnect in background
// This ensures the page loads quickly even if WebSocket is slow
connectWebSocket().catch((err) => {
console.warn('[Store] WebSocket reconnection failed, will retry automatically:', err)
// The WebSocket client will handle retries automatically
console.warn('[Store] WebSocket reconnection failed, will retry:', err)
isReconnecting.value = true
})
return true
} catch (err) {
console.error('[Store] Session check failed:', err)
// Session invalid, clear auth
localStorage.removeItem('neode-auth')
isAuthenticated.value = false
sessionValidated = false
isWsSubscribed = false
isConnected.value = false
isReconnecting.value = false
// Disconnect WebSocket if session is invalid
wsClient.disconnect()
return false
}
}
function needsSessionValidation(): boolean {
return isAuthenticated.value && !sessionValidated
}
// Package actions
async function installPackage(id: string, marketplaceUrl: string, version: string): Promise<string> {
return rpcClient.installPackage(id, marketplaceUrl, version)
@ -293,6 +291,7 @@ export const useAppStore = defineStore('app', () => {
login,
logout,
checkSession,
needsSessionValidation,
connectWebSocket,
installPackage,
uninstallPackage,

View File

@ -16,14 +16,45 @@ function mustOpenInNewTab(url: string): boolean {
}
}
/** Rewrite to same-origin proxy so iframe can embed (nginx strips X-Frame-Options) */
/** Port → proxy path for apps (nginx strips X-Frame-Options) */
const PORT_TO_PROXY: Record<string, string> = {
'81': '/app/nginx-proxy-manager/',
'3000': '/app/grafana/',
'3001': '/app/uptime-kuma/',
'8080': '/app/endurain/',
'8081': '/app/lnd/',
'8082': '/app/vaultwarden/',
'8083': '/app/filebrowser/',
'8085': '/app/nextcloud/',
'8096': '/app/jellyfin/',
'8123': '/app/homeassistant/',
'8240': '/app/tailscale/',
'8334': '/app/bitcoin-ui/',
'8888': '/app/searxng/',
'9000': '/app/portainer/',
'9001': '/app/penpot/',
'9980': '/app/onlyoffice/',
'11434': '/app/ollama/',
'2283': '/app/immich/',
'23000': '/app/btcpay/',
'2342': '/app/photoprism/',
'4080': '/app/mempool/',
'50002': '/app/electrs/',
'8175': '/app/fedimint/',
}
/** Rewrite to same-origin proxy so iframe can embed (avoids mixed content on HTTPS) */
function toEmbeddableUrl(url: string): string {
try {
const u = new URL(url)
const origin = window.location.origin
// Only Vaultwarden and Penpot support subpath proxy; Nextcloud/Immich open in new tab
if (u.port === '8082') return `${origin}/app/vaultwarden/`
if (u.port === '9001') return `${origin}/app/penpot/`
const proxyPath = PORT_TO_PROXY[u.port]
const sameHost = u.hostname === window.location.hostname
const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:'
// Use proxy when: (a) mixed content, or (b) vaultwarden/penpot always (subpath required)
if (proxyPath && sameHost && (needsProxy || u.port === '8082' || u.port === '9001')) {
return `${origin}${proxyPath}`
}
} catch {
/* ignore */
}
@ -37,12 +68,13 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
let previousActiveElement: HTMLElement | null = null
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
const embeddableUrl = toEmbeddableUrl(payload.url)
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
window.open(payload.url, '_blank', 'noopener,noreferrer')
window.open(embeddableUrl, '_blank', 'noopener,noreferrer')
return
}
previousActiveElement = (document.activeElement as HTMLElement) || null
url.value = toEmbeddableUrl(payload.url)
url.value = embeddableUrl
title.value = payload.title
isOpen.value = true
}

View File

@ -556,7 +556,7 @@ html::before {
opacity: 0;
}
/* Subtle black glitch overlay */
/* Subtle black glitch overlay - delay to avoid flash of partial clip-path on first paint (black crescent bug) */
body::before {
background-image: url('/assets/img/bg.jpg');
background-size: auto 100vh;
@ -566,6 +566,8 @@ body::before {
filter: brightness(0.4) contrast(1.2);
will-change: transform, clip-path, opacity;
animation: bg-glitch-shift-repeat 5s steps(10, end) infinite;
animation-delay: 1.5s;
animation-fill-mode: backwards;
}
/* Second subtle black layer */
@ -578,6 +580,8 @@ html::before {
filter: brightness(0.3) contrast(1.3);
will-change: transform, clip-path, opacity;
animation: bg-glitch-shift-2-repeat 5s steps(9, end) infinite;
animation-delay: 1.6s;
animation-fill-mode: backwards;
}
/* Subtle scanline sweep */
@ -588,6 +592,8 @@ body::after {
radial-gradient(ellipse at center, rgba(0,0,0,0) 40%, rgba(0,0,0,0.15) 100%);
will-change: transform, opacity;
animation: bg-glitch-scan-repeat 5s ease-out infinite;
animation-delay: 1.5s;
animation-fill-mode: backwards;
}
/* Dashboard: full viewport width, no letterboxing */

View File

@ -515,15 +515,15 @@ export const dummyApps: Record<string, PackageDataEntry> = {
'static-files': {
license: 'MIT',
instructions: 'Decentralized media streaming platform',
icon: '/assets/img/app-icons/indeedhub.png'
icon: 'https://indeehub.studio/favicon.ico'
},
manifest: {
id: 'indeedhub',
title: 'IndeedHub',
title: 'Indeehub',
version: '0.1.0',
description: {
short: 'Decentralized media streaming platform',
long: 'IndeedHub is a decentralized media streaming platform built on Nostr. Stream Bitcoin-focused documentaries, educational content, and independent films. Netflix-inspired interface with glassmorphism design, supporting content creators through the decentralized web.'
long: 'Indeehub is a decentralized media streaming platform built on Nostr. Stream Bitcoin-focused documentaries, educational content, and independent films. Netflix-inspired interface with glassmorphism design, supporting content creators through the decentralized web.'
},
'release-notes': 'Initial release with Netflix-inspired interface',
license: 'MIT',
@ -539,8 +539,8 @@ export const dummyApps: Record<string, PackageDataEntry> = {
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'indeedhub.onion',
'lan-address': 'http://localhost:7777'
'tor-address': '',
'lan-address': 'https://archipelago.indeehub.studio'
}
},
status: ServiceStatus.Running

View File

@ -643,8 +643,8 @@ function launchApp() {
prod: 'http://localhost:8103' // Self-hosted splash screen
},
'indeedhub': {
dev: 'http://localhost:7777',
prod: 'http://localhost:7777' // Containerized indeehub prototype
dev: 'https://archipelago.indeehub.studio',
prod: 'https://archipelago.indeehub.studio'
},
// Dummy apps - replace with real URLs when packaged
'bitcoin': {

View File

@ -245,8 +245,8 @@ function launchApp(id: string) {
prod: 'http://localhost:8103' // Self-hosted splash screen
},
'indeedhub': {
dev: 'http://localhost:7777',
prod: 'http://localhost:7777' // Containerized indeehub prototype
dev: 'https://archipelago.indeehub.studio',
prod: 'https://archipelago.indeehub.studio'
}
}

View File

@ -701,8 +701,14 @@ function getIconPath(iconName: string): string[] {
}
async function handleLogout() {
try {
await store.logout()
router.push('/login')
} catch {
/* proceed to login regardless */
}
router.push('/login').catch(() => {
window.location.href = '/login'
})
}
// Track previous route for transition logic

View File

@ -136,7 +136,7 @@ import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { useAppStore } from '../stores/app'
import { useLoginTransitionStore } from '../stores/loginTransition'
import { rpcClient } from '../api/rpc-client'
import { startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
const router = useRouter()
const store = useAppStore()
@ -155,17 +155,25 @@ const isSetupMode = computed(() => {
})
onMounted(async () => {
if (sessionStorage.getItem('archipelago_from_splash') !== '1') {
const fromSplash = sessionStorage.getItem('archipelago_from_splash') === '1'
if (fromSplash) sessionStorage.removeItem('archipelago_from_splash')
const unlock = () => {
if (!fromSplash) {
resumeAudioContext()
startSynthwave()
} else {
sessionStorage.removeItem('archipelago_from_splash')
}
document.removeEventListener('click', unlock)
document.removeEventListener('touchstart', unlock)
document.removeEventListener('keydown', unlock)
}
document.addEventListener('click', unlock, { once: true })
document.addEventListener('touchstart', unlock, { once: true })
document.addEventListener('keydown', unlock, { once: true })
if (isSetupMode.value) {
try {
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {} })
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
isSetup.value = Boolean(result)
} catch (err) {
console.error('Failed to check setup status:', err)
} catch {
isSetup.value = false
}
} else {
@ -207,10 +215,17 @@ async function handleSetup() {
loginTransition.setJustLoggedIn(true)
await store.login(password.value)
await new Promise(r => setTimeout(r, 520))
router.replace({ name: 'home' })
await router.replace({ name: 'home' }).catch(() => {
window.location.href = '/dashboard'
})
} catch (err) {
whooshAway.value = false
error.value = err instanceof Error ? err.message : 'Setup failed. Please try again.'
const msg = err instanceof Error ? err.message : ''
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
error.value = 'Server is starting up. Please try again in a moment.'
} else {
error.value = msg || 'Setup failed. Please try again.'
}
startSynthwave()
} finally {
loading.value = false
@ -237,10 +252,17 @@ async function handleLogin() {
playLoginSuccessWhoosh()
loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520))
router.replace({ name: 'home' })
await router.replace({ name: 'home' }).catch(() => {
window.location.href = '/dashboard'
})
} catch (err) {
whooshAway.value = false
error.value = err instanceof Error ? err.message : 'Login failed. Please check your password.'
const msg = err instanceof Error ? err.message : ''
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
error.value = 'Server is starting up. Please try again in a moment.'
} else {
error.value = msg || 'Login failed. Please check your password.'
}
startSynthwave()
} finally {
loading.value = false

View File

@ -851,6 +851,17 @@ function getCuratedAppList() {
dockerImage: 'docker.io/fedimint/fedimintd:v0.10.0',
manifestUrl: null,
repoUrl: 'https://github.com/fedimint/fedimint'
},
{
id: 'indeedhub',
title: 'Indeehub',
version: '0.1.0',
description: 'Bitcoin documentary streaming platform. Stream God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.',
icon: 'https://indeehub.studio/favicon.ico',
author: 'Indeehub Team',
dockerImage: 'localhost/indeedhub:latest',
manifestUrl: null,
repoUrl: 'https://github.com/indeedhub/indeedhub'
}
]
}

View File

@ -139,11 +139,11 @@ async function downloadBackup() {
}
function proceed() {
router.push('/onboarding/verify')
router.push('/onboarding/verify').catch(() => {})
}
function skipForNow() {
router.push('/onboarding/verify')
router.push('/onboarding/verify').catch(() => {})
}
</script>

View File

@ -101,30 +101,33 @@ async function generateDid() {
isGenerating.value = true
errorMessage.value = ''
for (let attempt = 0; attempt < 3; attempt++) {
try {
const { did, pubkey } = await rpcClient.getNodeDid()
generatedDid.value = did
localStorage.setItem('neode_did', did)
localStorage.setItem('neode_did_state', JSON.stringify({ did, kid: pubkey }))
break
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : 'Failed to load node identity'
// Fallback: show placeholder if backend unavailable (e.g. mock mode)
if (!generatedDid.value) {
errorMessage.value = err instanceof Error ? err.message : 'Server unavailable. Retrying...'
if (attempt < 2) {
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
} else {
generatedDid.value = 'did:key:z6Mk... (connect to server)'
}
} finally {
isGenerating.value = false
}
}
isGenerating.value = false
}
function proceed() {
if (generatedDid.value && !generatedDid.value.includes('...')) {
router.push('/onboarding/backup')
router.push('/onboarding/backup').catch(() => {})
}
}
function skipForNow() {
router.push('/onboarding/backup')
router.push('/onboarding/backup').catch(() => {})
}
</script>

View File

@ -60,7 +60,7 @@ import { useRouter } from 'vue-router'
const router = useRouter()
function goToLogin() {
router.push('/login')
router.push('/login').catch(() => {})
}
</script>

View File

@ -38,7 +38,7 @@ import { useRouter } from 'vue-router'
const router = useRouter()
function goToOptions() {
router.push('/onboarding/path')
router.push('/onboarding/path').catch(() => {})
}
</script>

View File

@ -99,8 +99,12 @@ function selectOption(option: string) {
async function proceed() {
if (selected.value) {
try {
await completeOnboarding()
router.push('/login')
} catch {
/* localStorage fallback ensures onboarding is marked complete */
}
router.push('/login').catch(() => {})
}
}
</script>

View File

@ -139,15 +139,12 @@ function toggleOption(option: string) {
}
function proceed() {
// Save selected options to localStorage
localStorage.setItem('neode_selected_paths', JSON.stringify(selectedOptions.value))
// Don't mark onboarding complete yet - continue to DID creation
router.push('/onboarding/did')
router.push('/onboarding/did').catch(() => {})
}
function skipForNow() {
// Skip to DID creation
router.push('/onboarding/did')
router.push('/onboarding/did').catch(() => {})
}
</script>

View File

@ -113,13 +113,21 @@ function generateMockSignature(): string {
}
async function proceed() {
try {
await completeOnboarding()
router.push('/login')
} catch {
/* localStorage fallback ensures we can proceed */
}
router.push('/login').catch(() => {})
}
async function skipForNow() {
try {
await completeOnboarding()
router.push('/login')
} catch {
/* localStorage fallback ensures we can proceed */
}
router.push('/login').catch(() => {})
}
</script>

View File

@ -322,16 +322,18 @@ onMounted(() => {
isTransitioning.value = false
isGlitching.value = false
document.body.classList.add('video-background-active')
const unlock = () => {
resumeAudioContext()
if (sessionStorage.getItem('archipelago_from_splash') !== '1') {
startSynthwave()
}
const unlock = () => {
resumeAudioContext()
document.removeEventListener('click', unlock)
document.removeEventListener('touchstart', unlock)
document.removeEventListener('keydown', unlock)
}
document.addEventListener('click', unlock, { once: true })
document.addEventListener('touchstart', unlock, { once: true })
document.addEventListener('keydown', unlock, { once: true })
}
})
</script>

View File

@ -20,11 +20,20 @@ const router = useRouter()
onMounted(async () => {
const devMode = import.meta.env.VITE_DEV_MODE
if (devMode === 'setup' || devMode === 'existing') {
router.replace('/login')
router.replace('/login').catch(() => {})
return
}
const seenOnboarding = await isOnboardingComplete()
router.replace(seenOnboarding ? '/login' : '/onboarding/intro')
let seenOnboarding = false
try {
const result = await Promise.race([
isOnboardingComplete(),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 5000)),
])
seenOnboarding = result
} catch {
seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1'
}
router.replace(seenOnboarding ? '/login' : '/onboarding/intro').catch(() => {})
})
</script>

View File

@ -507,7 +507,10 @@ if [ "$LIVE" = true ]; then
# Recreate Fedimint with FM_API_URL for Guardian UI (fixes "Api URL must be configured")
echo " Fixing Fedimint API URL..."
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
TIMEOUT_CMD=""
command -v timeout >/dev/null 2>&1 && TIMEOUT_CMD="timeout 90"
command -v gtimeout >/dev/null 2>&1 && TIMEOUT_CMD="gtimeout 90"
($TIMEOUT_CMD sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
for c in \$(sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E '^fedimint\$'); do
@ -530,7 +533,7 @@ if [ "$LIVE" = true ]; then
docker.io/fedimint/fedimintd:v0.10.0
break
done
" 2>&1 | sed 's/^/ /' || true
" 2>&1 | sed 's/^/ /') || echo " (Fedimint fix timed out or skipped - run manually if needed)"
echo ""
echo "✅ Deployed to live system!"

View File

@ -0,0 +1,178 @@
# App proxies for HTTPS - avoids mixed content when embedding apps from HTTPS page
# Complete list for all apps that may be launched from the UI
location /app/grafana/ {
proxy_pass http://127.0.0.1:3000/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/uptime-kuma/ {
proxy_pass http://127.0.0.1:3001/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/searxng/ {
proxy_pass http://127.0.0.1:8888/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/portainer/ {
proxy_pass http://127.0.0.1:9000/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/filebrowser/ {
proxy_pass http://127.0.0.1:8083/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/endurain/ {
proxy_pass http://127.0.0.1:8080/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/lnd/ {
proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/onlyoffice/ {
proxy_pass http://127.0.0.1:9980/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/jellyfin/ {
proxy_pass http://127.0.0.1:8096/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/photoprism/ {
proxy_pass http://127.0.0.1:2342/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/mempool/ {
proxy_pass http://127.0.0.1:4080/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/fedimint/ {
proxy_pass http://127.0.0.1:8175/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/tailscale/ {
proxy_pass http://127.0.0.1:8240/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/ollama/ {
proxy_pass http://127.0.0.1:11434/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/bitcoin-ui/ {
proxy_pass http://127.0.0.1:8334/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/electrs/ {
proxy_pass http://127.0.0.1:50002/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/nginx-proxy-manager/ {
proxy_pass http://127.0.0.1:81/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}

View File

@ -55,6 +55,21 @@ if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null && [ -f "$PWA_SNIPPET" ]; t
fi
fi
# Install app proxies snippet (mempool, fedimint, lnd, etc.) - fixes apps not opening over HTTPS (mixed content)
APPS_SNIPPET="$NGINX_SNIPPETS/archipelago-https-app-proxies.conf"
if [ -f "$SCRIPT_DIR/nginx-https-app-proxies.conf" ]; then
cp "$SCRIPT_DIR/nginx-https-app-proxies.conf" "$APPS_SNIPPET"
echo " HTTPS app proxies snippet installed at $APPS_SNIPPET"
# Add include to HTTPS block if missing
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null && ! grep -q "archipelago-https-app-proxies" "$NGINX_CFG" 2>/dev/null; then
echo " Adding app proxies include to HTTPS block..."
sed -i '/listen 443 ssl/,/^}$/{
/location \/ws {/i\
include snippets/archipelago-https-app-proxies.conf;
}' "$NGINX_CFG" 2>/dev/null || true
fi
fi
# Check if HTTPS is already configured
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null; then
echo "HTTPS already configured in nginx."
@ -102,6 +117,121 @@ server {
proxy_read_timeout 600s;
}
location /app/nextcloud/ {
proxy_pass http://127.0.0.1:8085/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/vaultwarden/ {
proxy_pass http://127.0.0.1:8082/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/immich/ {
proxy_pass http://127.0.0.1:2283/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/penpot/ {
proxy_pass http://127.0.0.1:9001/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/btcpay/ {
proxy_pass http://127.0.0.1:23000/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/homeassistant/ {
proxy_pass http://127.0.0.1:8123/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
location /app/mempool/ {
proxy_pass http://127.0.0.1:4080/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/fedimint/ {
proxy_pass http://127.0.0.1:8175/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/lnd/ {
proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/bitcoin-ui/ {
proxy_pass http://127.0.0.1:8334/;
proxy_http_version 1.1;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /ws {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;

View File

@ -0,0 +1,66 @@
#!/bin/bash
#
# Trust the Archipelago server's self-signed certificate on macOS.
# Run this to eliminate "Not secure" when accessing https://192.168.1.228
#
# Usage: ./scripts/trust-archipelago-cert.sh [host]
# Default host: 192.168.1.228
#
# Requires: SSH access to archipelago@host (uses deploy-config.sh password)
#
set -e
HOST="${1:-192.168.1.228}"
CERT_FILE="/tmp/archipelago-${HOST}.crt"
KEYCHAIN="${HOME}/Library/Keychains/login.keychain-db"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
# Try to fetch cert from server via SSH (most reliable)
if [ -f "$SCRIPT_DIR/deploy-config.sh" ]; then
. "$SCRIPT_DIR/deploy-config.sh"
SSH_OPTS="-o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no"
if command -v sshpass >/dev/null 2>&1; then
echo "Fetching certificate from server..."
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS archipelago@${HOST} \
'sudo -n cat /etc/archipelago/ssl/archipelago.crt' > "$CERT_FILE" 2>/dev/null || true
fi
fi
# Fallback: fetch via openssl (can hang on some systems)
if [ ! -s "$CERT_FILE" ]; then
echo "Fetching certificate via TLS..."
(echo "Q"; sleep 1) | openssl s_client -connect "${HOST}:443" -servername "${HOST}" 2>/dev/null | \
openssl x509 -outform PEM > "$CERT_FILE"
fi
if [ ! -s "$CERT_FILE" ]; then
echo "Failed to fetch certificate. Ensure deploy-config.sh exists and SSH works, or the server is reachable."
exit 1
fi
echo "Adding to your login keychain..."
# Remove old cert if present (by common name)
security delete-certificate -c "archipelago.local" "$KEYCHAIN" 2>/dev/null || true
# Add to user keychain with trust (no sudo needed)
if security add-trusted-cert -d -r trustRoot -k "$KEYCHAIN" "$CERT_FILE" 2>/dev/null; then
echo " Certificate trusted successfully."
elif security add-trusted-cert -d -r trustAsRoot -k "$KEYCHAIN" "$CERT_FILE" 2>/dev/null; then
echo " Certificate trusted successfully."
else
# Fallback: add cert and open Keychain Access for manual trust
cp "$CERT_FILE" "$HOME/Desktop/archipelago-${HOST}.crt"
echo ""
echo " Could not auto-trust. Certificate saved to Desktop."
echo " Double-click archipelago-${HOST}.crt to add it, then in Keychain Access"
echo " find it, double-click, expand Trust → set to 'Always Trust'."
CERT_FILE="" # Don't delete, we copied to Desktop
fi
rm -f "$CERT_FILE"
echo ""
echo "✅ Done. Restart your browser fully (quit Chrome/Safari) and visit https://${HOST}"