feat: NostrVPN as native system service, Claude API key input, fix duplicate password
- 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:
parent
dc6496e693
commit
e97fee2d7e
@ -16,6 +16,7 @@ const VPN_CONFIG_FILE: &str = "vpn-config.json";
|
|||||||
pub enum VpnProvider {
|
pub enum VpnProvider {
|
||||||
Tailscale,
|
Tailscale,
|
||||||
Wireguard,
|
Wireguard,
|
||||||
|
NostrVpn,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persisted VPN configuration.
|
/// Persisted VPN configuration.
|
||||||
@ -172,6 +173,11 @@ pub fn generate_wireguard_conf(config: &WireGuardConfig) -> String {
|
|||||||
|
|
||||||
/// Get the current VPN status by checking network interfaces.
|
/// Get the current VPN status by checking network interfaces.
|
||||||
pub async fn get_status() -> VpnStatus {
|
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
|
// Check for Tailscale interface
|
||||||
if let Ok(tailscale) = get_tailscale_status().await {
|
if let Ok(tailscale) = get_tailscale_status().await {
|
||||||
return tailscale;
|
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> {
|
async fn get_tailscale_status() -> Result<VpnStatus> {
|
||||||
// Check if tailscale0 interface exists
|
// Check if tailscale0 interface exists
|
||||||
let output = tokio::process::Command::new("ip")
|
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-reconcile.timer /etc/systemd/system/archipelago-reconcile.timer
|
||||||
COPY archipelago-tor-helper.service /etc/systemd/system/archipelago-tor-helper.service
|
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 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)
|
# Copy container doctor + reconcile scripts (referenced by the services above)
|
||||||
RUN mkdir -p /home/archipelago/archy/scripts/lib
|
RUN mkdir -p /home/archipelago/archy/scripts/lib
|
||||||
@ -474,6 +475,12 @@ NGINXCONF
|
|||||||
echo " Using tor-helper path unit from configs/"
|
echo " Using tor-helper path unit from configs/"
|
||||||
fi
|
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)
|
# Use archipelago.service from configs/ (User=root for Podman container access)
|
||||||
if [ -f "$SCRIPT_DIR/configs/archipelago.service" ]; then
|
if [ -f "$SCRIPT_DIR/configs/archipelago.service" ]; then
|
||||||
cp "$SCRIPT_DIR/configs/archipelago.service" "$WORK_DIR/archipelago.service"
|
cp "$SCRIPT_DIR/configs/archipelago.service" "$WORK_DIR/archipelago.service"
|
||||||
@ -923,6 +930,29 @@ BACKENDFILE
|
|||||||
fi
|
fi
|
||||||
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
|
# Capture web UI from live server
|
||||||
if [ "$BUILD_FROM_SOURCE" = "1" ]; then
|
if [ "$BUILD_FROM_SOURCE" = "1" ]; then
|
||||||
echo " Building web UI from source..."
|
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">
|
<script setup lang="ts">
|
||||||
import AccountSection from '@/views/settings/AccountSection.vue'
|
import AccountSection from '@/views/settings/AccountSection.vue'
|
||||||
import ChangePasswordSection from '@/views/settings/ChangePasswordSection.vue'
|
|
||||||
import SystemSection from '@/views/settings/SystemSection.vue'
|
import SystemSection from '@/views/settings/SystemSection.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
<AccountSection />
|
<AccountSection />
|
||||||
<ChangePasswordSection />
|
|
||||||
<SystemSection />
|
<SystemSection />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,104 +1,100 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const claudeConnected = ref(false)
|
const apiKey = ref('')
|
||||||
const showClaudeLoginModal = ref(false)
|
const saved = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const hasKey = ref(false)
|
||||||
|
|
||||||
function checkClaudeStatus() {
|
async function checkApiKey() {
|
||||||
fetch('/aiui/api/claude/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'haiku', messages: [{ role: 'user', content: 'ping' }] }) })
|
try {
|
||||||
.then(r => {
|
const result = await rpcClient.call({ method: 'system.settings.get', params: { key: 'claude_api_key_set' } }) as { value: boolean } | null
|
||||||
if (!r.ok) { claudeConnected.value = false; return }
|
hasKey.value = !!result?.value
|
||||||
const reader = r.body?.getReader()
|
} catch {
|
||||||
if (!reader) return
|
hasKey.value = false
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Claude Authentication Section -->
|
|
||||||
<div class="glass-card px-6 py-6 mb-6">
|
<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>
|
<div class="flex items-center gap-3 mb-2">
|
||||||
<p class="text-sm text-white/60 mb-6">{{ t('settings.claudeAuthDesc') }}</p>
|
<div class="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center">
|
||||||
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4" data-controller-ignore>
|
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<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 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" />
|
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.connectionStatus') }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-base font-medium" :class="claudeConnected ? 'text-green-400' : 'text-white/50'">
|
<div>
|
||||||
{{ claudeConnected ? t('common.connected') : t('settings.notConnected') }}
|
<h3 class="text-base font-semibold text-white/96">{{ t('settings.claudeAuth') }}</h3>
|
||||||
</p>
|
<p class="text-sm text-white/50">Enter your Anthropic API key for AI features</p>
|
||||||
|
</div>
|
||||||
</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 -->
|
<div class="mt-4">
|
||||||
<Teleport to="body">
|
<div v-if="hasKey && !apiKey" class="flex items-center justify-between bg-white/5 rounded-lg px-4 py-3 mb-3">
|
||||||
<div
|
<div class="flex items-center gap-2">
|
||||||
v-if="showClaudeLoginModal"
|
<span class="w-2 h-2 rounded-full bg-green-400"></span>
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
|
<span class="text-sm text-white/80">API key configured</span>
|
||||||
@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>
|
</div>
|
||||||
<iframe
|
<button @click="removeApiKey" class="text-xs text-red-400 hover:text-red-300 transition-colors">Remove</button>
|
||||||
src="/claude-login"
|
|
||||||
class="w-full border-0"
|
|
||||||
style="height: calc(100% - 49px)"
|
|
||||||
@load="onClaudeIframeLoad"
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Teleport>
|
</div>
|
||||||
</template>
|
</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
|
chown -R archipelago:archipelago "$TOR_HOSTNAMES" 2>/dev/null
|
||||||
log "Tor hostnames populated: $(ls $TOR_HOSTNAMES 2>/dev/null | tr '\n' ' ')"
|
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 a container to be healthy (accepting connections)
|
||||||
wait_for_container() {
|
wait_for_container() {
|
||||||
local name="$1" check_cmd="$2" max_wait="${3:-30}"
|
local name="$1" check_cmd="$2" max_wait="${3:-30}"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user