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:
Dorian 2026-04-07 14:40:33 +01:00
parent dc6496e693
commit e97fee2d7e
7 changed files with 348 additions and 85 deletions

View File

@ -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")

View File

@ -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..."

View 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

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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}"