feat: NostrVPN as native system service, Claude API key input, fix duplicate password
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Failing after 1m0s
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Failing after 1m0s
- Add NostrVPN as a native systemd service (extracted from container) - Add VPN status detection for nostr-vpn in backend vpn.rs - ISO build extracts nvpn binary from container image - First-boot auto-configures NostrVPN with node's Nostr identity - Change Claude Auth from login iframe to API key input field - Remove duplicate ChangePasswordSection from Settings.vue - FIPS and Routstr remain as installable container apps Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ const VPN_CONFIG_FILE: &str = "vpn-config.json";
|
||||
pub enum VpnProvider {
|
||||
Tailscale,
|
||||
Wireguard,
|
||||
NostrVpn,
|
||||
}
|
||||
|
||||
/// Persisted VPN configuration.
|
||||
@@ -172,6 +173,11 @@ pub fn generate_wireguard_conf(config: &WireGuardConfig) -> String {
|
||||
|
||||
/// Get the current VPN status by checking network interfaces.
|
||||
pub async fn get_status() -> VpnStatus {
|
||||
// Check for NostrVPN (native system service)
|
||||
if let Ok(nvpn) = get_nostr_vpn_status().await {
|
||||
return nvpn;
|
||||
}
|
||||
|
||||
// Check for Tailscale interface
|
||||
if let Ok(tailscale) = get_tailscale_status().await {
|
||||
return tailscale;
|
||||
@@ -194,6 +200,101 @@ pub async fn get_status() -> VpnStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if NostrVPN system service is running and get its status.
|
||||
async fn get_nostr_vpn_status() -> Result<VpnStatus> {
|
||||
// Check if nostr-vpn service is active
|
||||
let active = tokio::process::Command::new("systemctl")
|
||||
.args(["is-active", "nostr-vpn"])
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !active {
|
||||
anyhow::bail!("nostr-vpn service not active");
|
||||
}
|
||||
|
||||
// Try to get status from nvpn CLI
|
||||
let output = tokio::process::Command::new("nvpn")
|
||||
.arg("status")
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let (peers, ip) = match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
let peers = stdout.lines()
|
||||
.filter(|l| l.contains("peer") || l.contains("connected"))
|
||||
.count() as u32;
|
||||
let ip = stdout.lines()
|
||||
.find(|l| l.contains("address") || l.contains("ip"))
|
||||
.and_then(|l| l.split_whitespace().last())
|
||||
.map(|s| s.to_string());
|
||||
(peers, ip)
|
||||
}
|
||||
_ => (0, None),
|
||||
};
|
||||
|
||||
Ok(VpnStatus {
|
||||
connected: true,
|
||||
provider: Some("nostr-vpn".to_string()),
|
||||
interface: Some("nvpn0".to_string()),
|
||||
ip_address: ip,
|
||||
hostname: None,
|
||||
peers_connected: peers,
|
||||
bytes_in: 0,
|
||||
bytes_out: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configure NostrVPN with the node's Nostr identity.
|
||||
pub async fn configure_nostr_vpn(data_dir: &Path) -> Result<()> {
|
||||
let nostr_secret = tokio::fs::read_to_string(
|
||||
data_dir.join("identity/nostr_secret")
|
||||
).await.context("No Nostr secret key — complete onboarding first")?;
|
||||
|
||||
let nostr_pubkey = tokio::fs::read_to_string(
|
||||
data_dir.join("identity/nostr_pubkey")
|
||||
).await.unwrap_or_default();
|
||||
|
||||
let vpn_dir = data_dir.join("nostr-vpn");
|
||||
tokio::fs::create_dir_all(&vpn_dir).await.context("Failed to create nostr-vpn dir")?;
|
||||
|
||||
// Write env file for the systemd service
|
||||
let env_content = format!(
|
||||
"NOSTR_SECRET={}\nNOSTR_PUBKEY={}\n",
|
||||
nostr_secret.trim(),
|
||||
nostr_pubkey.trim()
|
||||
);
|
||||
tokio::fs::write(vpn_dir.join("env"), &env_content)
|
||||
.await
|
||||
.context("Failed to write nostr-vpn env")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(
|
||||
vpn_dir.join("env"),
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
).ok();
|
||||
}
|
||||
|
||||
// Enable and start the service
|
||||
tokio::process::Command::new("systemctl")
|
||||
.args(["enable", "--now", "nostr-vpn"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to enable nostr-vpn service")?;
|
||||
|
||||
let mut config = load_config(data_dir).await?;
|
||||
config.provider = VpnProvider::NostrVpn;
|
||||
config.enabled = true;
|
||||
config.configured_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
save_config(data_dir, &config).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_tailscale_status() -> Result<VpnStatus> {
|
||||
// Check if tailscale0 interface exists
|
||||
let output = tokio::process::Command::new("ip")
|
||||
|
||||
@@ -365,6 +365,7 @@ COPY archipelago-reconcile.service /etc/systemd/system/archipelago-reconcile.ser
|
||||
COPY archipelago-reconcile.timer /etc/systemd/system/archipelago-reconcile.timer
|
||||
COPY archipelago-tor-helper.service /etc/systemd/system/archipelago-tor-helper.service
|
||||
COPY archipelago-tor-helper.path /etc/systemd/system/archipelago-tor-helper.path
|
||||
COPY nostr-vpn.service /etc/systemd/system/nostr-vpn.service
|
||||
|
||||
# Copy container doctor + reconcile scripts (referenced by the services above)
|
||||
RUN mkdir -p /home/archipelago/archy/scripts/lib
|
||||
@@ -474,6 +475,12 @@ NGINXCONF
|
||||
echo " Using tor-helper path unit from configs/"
|
||||
fi
|
||||
|
||||
# Copy NostrVPN system service (native mesh VPN, not a container)
|
||||
if [ -f "$SCRIPT_DIR/configs/nostr-vpn.service" ]; then
|
||||
cp "$SCRIPT_DIR/configs/nostr-vpn.service" "$WORK_DIR/nostr-vpn.service"
|
||||
echo " Using nostr-vpn.service from configs/"
|
||||
fi
|
||||
|
||||
# Use archipelago.service from configs/ (User=root for Podman container access)
|
||||
if [ -f "$SCRIPT_DIR/configs/archipelago.service" ]; then
|
||||
cp "$SCRIPT_DIR/configs/archipelago.service" "$WORK_DIR/archipelago.service"
|
||||
@@ -923,6 +930,29 @@ BACKENDFILE
|
||||
fi
|
||||
fi
|
||||
|
||||
# Extract NostrVPN binary from container image (native system service, not a container app)
|
||||
echo " Extracting NostrVPN binary..."
|
||||
NVPN_IMAGE="$($CONTAINER_CMD images -q 80.71.235.15:3000/archipelago/nostr-vpn:v0.3.4 2>/dev/null)"
|
||||
if [ -z "$NVPN_IMAGE" ]; then
|
||||
$CONTAINER_CMD pull 80.71.235.15:3000/archipelago/nostr-vpn:v0.3.4 2>/dev/null || true
|
||||
fi
|
||||
NVPN_CONTAINER=$($CONTAINER_CMD create 80.71.235.15:3000/archipelago/nostr-vpn:v0.3.4 2>/dev/null) || true
|
||||
if [ -n "$NVPN_CONTAINER" ]; then
|
||||
$CONTAINER_CMD cp "$NVPN_CONTAINER:/usr/local/bin/nvpn" "$ARCH_DIR/bin/nvpn" 2>/dev/null && \
|
||||
chmod +x "$ARCH_DIR/bin/nvpn" && \
|
||||
echo " ✅ NostrVPN binary extracted ($(du -h "$ARCH_DIR/bin/nvpn" | cut -f1))"
|
||||
$CONTAINER_CMD rm "$NVPN_CONTAINER" 2>/dev/null || true
|
||||
else
|
||||
echo " ⚠ NostrVPN image not available — nvpn binary will be missing"
|
||||
fi
|
||||
|
||||
# Copy NostrVPN UI dashboard for nginx serving
|
||||
if [ -d "$SCRIPT_DIR/../docker/nostr-vpn-ui" ]; then
|
||||
mkdir -p "$ARCH_DIR/web-ui/nostr-vpn"
|
||||
cp "$SCRIPT_DIR/../docker/nostr-vpn-ui/index.html" "$ARCH_DIR/web-ui/nostr-vpn/"
|
||||
echo " ✅ NostrVPN UI dashboard included"
|
||||
fi
|
||||
|
||||
# Capture web UI from live server
|
||||
if [ "$BUILD_FROM_SOURCE" = "1" ]; then
|
||||
echo " Building web UI from source..."
|
||||
|
||||
20
image-recipe/configs/nostr-vpn.service
Normal file
20
image-recipe/configs/nostr-vpn.service
Normal file
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=Nostr VPN - Mesh VPN with Nostr signaling
|
||||
After=network-online.target tor.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
EnvironmentFile=-/var/lib/archipelago/nostr-vpn/env
|
||||
ExecStart=/usr/local/bin/nvpn daemon
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
TimeoutStopSec=10
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,13 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import AccountSection from '@/views/settings/AccountSection.vue'
|
||||
import ChangePasswordSection from '@/views/settings/ChangePasswordSection.vue'
|
||||
import SystemSection from '@/views/settings/SystemSection.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<AccountSection />
|
||||
<ChangePasswordSection />
|
||||
<SystemSection />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,104 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const claudeConnected = ref(false)
|
||||
const showClaudeLoginModal = ref(false)
|
||||
const apiKey = ref('')
|
||||
const saved = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const hasKey = ref(false)
|
||||
|
||||
function checkClaudeStatus() {
|
||||
fetch('/aiui/api/claude/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'haiku', messages: [{ role: 'user', content: 'ping' }] }) })
|
||||
.then(r => {
|
||||
if (!r.ok) { claudeConnected.value = false; return }
|
||||
const reader = r.body?.getReader()
|
||||
if (!reader) return
|
||||
const decoder = new TextDecoder()
|
||||
let text = ''
|
||||
function read(): Promise<void> {
|
||||
return reader!.read().then(({ done, value }) => {
|
||||
if (done) {
|
||||
claudeConnected.value = !text.includes('Not logged in') && !text.includes('error')
|
||||
return
|
||||
}
|
||||
text += decoder.decode(value, { stream: true })
|
||||
return read()
|
||||
})
|
||||
}
|
||||
read()
|
||||
})
|
||||
.catch(() => { claudeConnected.value = false })
|
||||
}
|
||||
|
||||
function onClaudeIframeLoad() {
|
||||
window.addEventListener('message', handleClaudeLoginMessage)
|
||||
}
|
||||
|
||||
function handleClaudeLoginMessage(e: MessageEvent) {
|
||||
if (e.data?.type === 'claude-auth-success') {
|
||||
claudeConnected.value = true
|
||||
showClaudeLoginModal.value = false
|
||||
window.removeEventListener('message', handleClaudeLoginMessage)
|
||||
async function checkApiKey() {
|
||||
try {
|
||||
const result = await rpcClient.call({ method: 'system.settings.get', params: { key: 'claude_api_key_set' } }) as { value: boolean } | null
|
||||
hasKey.value = !!result?.value
|
||||
} catch {
|
||||
hasKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
checkClaudeStatus()
|
||||
async function saveApiKey() {
|
||||
if (!apiKey.value.startsWith('sk-ant-')) {
|
||||
error.value = 'API key should start with sk-ant-'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
saved.value = false
|
||||
try {
|
||||
await rpcClient.call({ method: 'system.settings.set', params: { key: 'claude_api_key', value: apiKey.value } })
|
||||
saved.value = true
|
||||
hasKey.value = true
|
||||
apiKey.value = ''
|
||||
setTimeout(() => { saved.value = false }, 3000)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to save API key'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeApiKey() {
|
||||
try {
|
||||
await rpcClient.call({ method: 'system.settings.set', params: { key: 'claude_api_key', value: '' } })
|
||||
hasKey.value = false
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to remove API key'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(checkApiKey)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Claude Authentication Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.claudeAuth') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-6">{{ t('settings.claudeAuthDesc') }}</p>
|
||||
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4" data-controller-ignore>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-5 h-5 shrink-0" :class="claudeConnected ? 'text-green-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-if="claudeConnected" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 11-12.728 0M12 9v4m0 4h.01" />
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.connectionStatus') }}</p>
|
||||
</div>
|
||||
<p class="text-base font-medium" :class="claudeConnected ? 'text-green-400' : 'text-white/50'">
|
||||
{{ claudeConnected ? t('common.connected') : t('settings.notConnected') }}
|
||||
</p>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-white/96">{{ t('settings.claudeAuth') }}</h3>
|
||||
<p class="text-sm text-white/50">Enter your Anthropic API key for AI features</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="showClaudeLoginModal = true"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors"
|
||||
:class="claudeConnected
|
||||
? 'border-white/20 text-white/70 hover:bg-white/5'
|
||||
: 'glass-button-warning font-medium'"
|
||||
>
|
||||
<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="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>{{ claudeConnected ? t('settings.reAuthenticate') : t('settings.loginWithClaude') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Claude Login Modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showClaudeLoginModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
|
||||
@click.self="showClaudeLoginModal = false"
|
||||
>
|
||||
<div class="glass-card p-0 max-w-lg w-full overflow-hidden" style="height: 480px">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
||||
<h3 class="text-sm font-semibold text-white/80">{{ t('settings.claudeAuth') }}</h3>
|
||||
<button @click="showClaudeLoginModal = false" class="text-white/50 hover:text-white/80 transition-colors">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="mt-4">
|
||||
<div v-if="hasKey && !apiKey" class="flex items-center justify-between bg-white/5 rounded-lg px-4 py-3 mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-green-400"></span>
|
||||
<span class="text-sm text-white/80">API key configured</span>
|
||||
</div>
|
||||
<iframe
|
||||
src="/claude-login"
|
||||
class="w-full border-0"
|
||||
style="height: calc(100% - 49px)"
|
||||
@load="onClaudeIframeLoad"
|
||||
/>
|
||||
<button @click="removeApiKey" class="text-xs text-red-400 hover:text-red-300 transition-colors">Remove</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="apiKey"
|
||||
type="password"
|
||||
:placeholder="hasKey ? 'Replace existing key...' : 'sk-ant-...'"
|
||||
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30 transition-colors"
|
||||
@keyup.enter="saveApiKey"
|
||||
/>
|
||||
<button
|
||||
@click="saveApiKey"
|
||||
:disabled="!apiKey || saving"
|
||||
class="glass-button px-4 py-2.5 text-sm font-medium disabled:opacity-30"
|
||||
>
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="saved" class="text-sm text-green-400 mt-2">API key saved successfully</p>
|
||||
<p v-if="error" class="text-sm text-red-400 mt-2">{{ error }}</p>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
98
neode-ui/src/views/settings/VpnStatusSection.vue
Normal file
98
neode-ui/src/views/settings/VpnStatusSection.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface VpnStatus {
|
||||
connected: boolean
|
||||
provider: string | null
|
||||
interface: string | null
|
||||
ip_address: string | null
|
||||
hostname: string | null
|
||||
peers_connected: number
|
||||
bytes_in: number
|
||||
bytes_out: number
|
||||
}
|
||||
|
||||
const vpnStatus = ref<VpnStatus | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
async function fetchVpnStatus() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const result = await rpcClient.call({ method: 'vpn.status', params: {} })
|
||||
vpnStatus.value = result as VpnStatus
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to get VPN status'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
onMounted(fetchVpnStatus)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-white/96">Nostr VPN</h3>
|
||||
<p class="text-sm text-white/50">Mesh VPN with Nostr signaling</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="vpnStatus" class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2.5 h-2.5 rounded-full"
|
||||
:class="vpnStatus.connected ? 'bg-green-400 animate-pulse' : 'bg-white/30'"
|
||||
/>
|
||||
<span class="text-sm" :class="vpnStatus.connected ? 'text-green-400' : 'text-white/50'">
|
||||
{{ vpnStatus.connected ? 'Connected' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-sm text-white/50">Loading VPN status...</div>
|
||||
|
||||
<div v-else-if="vpnStatus?.connected" class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-white/5 rounded-lg px-3 py-2">
|
||||
<div class="text-xs text-white/50 mb-1">Provider</div>
|
||||
<div class="text-sm font-medium text-white/90">{{ vpnStatus.provider || 'nostr-vpn' }}</div>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-lg px-3 py-2">
|
||||
<div class="text-xs text-white/50 mb-1">Peers</div>
|
||||
<div class="text-sm font-medium text-white/90">{{ vpnStatus.peers_connected }}</div>
|
||||
</div>
|
||||
<div v-if="vpnStatus.ip_address" class="bg-white/5 rounded-lg px-3 py-2">
|
||||
<div class="text-xs text-white/50 mb-1">VPN Address</div>
|
||||
<div class="text-sm font-mono text-white/90">{{ vpnStatus.ip_address }}</div>
|
||||
</div>
|
||||
<div v-if="vpnStatus.bytes_in || vpnStatus.bytes_out" class="bg-white/5 rounded-lg px-3 py-2">
|
||||
<div class="text-xs text-white/50 mb-1">Traffic</div>
|
||||
<div class="text-sm text-white/90">{{ formatBytes(vpnStatus.bytes_in) }} / {{ formatBytes(vpnStatus.bytes_out) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!vpnStatus?.connected" class="text-sm text-white/50">
|
||||
VPN will activate automatically when peers are discovered via Nostr relays.
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-sm text-red-400 mt-2">{{ error }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -86,6 +86,26 @@ done
|
||||
chown -R archipelago:archipelago "$TOR_HOSTNAMES" 2>/dev/null
|
||||
log "Tor hostnames populated: $(ls $TOR_HOSTNAMES 2>/dev/null | tr '\n' ' ')"
|
||||
|
||||
# ── NostrVPN: configure native system service with node identity ──────
|
||||
if command -v nvpn >/dev/null 2>&1; then
|
||||
NOSTR_SECRET=$(cat /var/lib/archipelago/identity/nostr_secret 2>/dev/null)
|
||||
NOSTR_PUBKEY=$(cat /var/lib/archipelago/identity/nostr_pubkey 2>/dev/null)
|
||||
if [ -n "$NOSTR_SECRET" ]; then
|
||||
mkdir -p /var/lib/archipelago/nostr-vpn
|
||||
cat > /var/lib/archipelago/nostr-vpn/env <<NVPNENV
|
||||
NOSTR_SECRET=${NOSTR_SECRET}
|
||||
NOSTR_PUBKEY=${NOSTR_PUBKEY}
|
||||
NVPNENV
|
||||
chmod 600 /var/lib/archipelago/nostr-vpn/env
|
||||
systemctl enable --now nostr-vpn 2>/dev/null || true
|
||||
log "NostrVPN configured with node identity and started"
|
||||
else
|
||||
log "NostrVPN: no Nostr identity yet — will configure after onboarding"
|
||||
fi
|
||||
else
|
||||
log "NostrVPN binary not found — skipping VPN setup"
|
||||
fi
|
||||
|
||||
# Wait for a container to be healthy (accepting connections)
|
||||
wait_for_container() {
|
||||
local name="$1" check_cmd="$2" max_wait="${3:-30}"
|
||||
|
||||
Reference in New Issue
Block a user