-
-
Notifications
You must be signed in to change notification settings - Fork 385
Description
Reporting a bug?
App works in development but not in production!
Expected behavior
To work both ways
Reproduction
<!--
MIT License
Copyright (c) 2025 MythicalSystems and Contributors
Copyright (c) 2025 Cassian Gherman (NaysKutzu)
Copyright (c) 2018 - 2021 Dane Everitt <dane@daneeveritt.com> and Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<template>
<DashboardLayout :breadcrumbs="breadcrumbs">
<div class="space-y-6 pb-8">
<WidgetRenderer v-if="widgetsTopOfPage.length > 0" :widgets="widgetsTopOfPage" />
<!-- Header Section -->
<div class="flex flex-col gap-4">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div class="space-y-1">
<h1 class="text-2xl sm:text-3xl font-bold tracking-tight">
{{ t('serverProxy.title') }}
</h1>
<p class="text-sm text-muted-foreground">
{{ t('serverProxy.description') }}
<span v-if="proxyEnabled" class="font-medium">
({{ proxies.length }}/{{ settingsStore.serverProxyMaxPerServer }})
</span>
</p>
</div>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
:disabled="loading"
class="flex items-center gap-2"
@click="refresh"
>
<RefreshCw :class="['h-4 w-4', loading && 'animate-spin']" />
<span>{{ t('common.refresh') }}</span>
</Button>
<Button
v-if="canManageProxy && proxyEnabled"
size="sm"
:disabled="loading || proxies.length >= settingsStore.serverProxyMaxPerServer"
class="flex items-center gap-2"
@click="openCreateDrawer"
>
<Plus class="h-4 w-4" />
<span>{{ t('serverProxy.createProxy') }}</span>
</Button>
</div>
</div>
<!-- Info Banner -->
<div
class="flex items-start gap-3 p-4 rounded-lg bg-blue-50 border-2 border-blue-200 dark:bg-blue-950/30 dark:border-blue-800"
>
<div class="h-10 w-10 rounded-lg bg-blue-500/20 flex items-center justify-center shrink-0">
<Info class="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div class="flex-1 min-w-0 space-y-1">
<h3 class="font-semibold text-blue-800 dark:text-blue-200">
{{ t('serverProxy.infoTitle') }}
</h3>
<p class="text-sm text-blue-700 dark:text-blue-300">
{{ t('serverProxy.infoDescription') }}
</p>
</div>
</div>
</div>
<WidgetRenderer v-if="widgetsAfterHeader.length > 0" :widgets="widgetsAfterHeader" />
<!-- Feature Disabled State -->
<Alert v-if="!proxyEnabled" variant="destructive" class="border-2">
<AlertTitle>{{ t('serverProxy.featureDisabled') }}</AlertTitle>
<AlertDescription>
{{ t('serverProxy.featureDisabledDescription') }}
</AlertDescription>
</Alert>
<!-- Loading State -->
<div v-else-if="loading && proxies.length === 0" class="flex flex-col items-center justify-center py-16">
<div class="animate-spin h-10 w-10 border-3 border-primary border-t-transparent rounded-full"></div>
<span class="mt-4 text-muted-foreground">{{ t('common.loading') }}</span>
</div>
<!-- Empty State -->
<div
v-else-if="!loading && proxies.length === 0"
class="flex flex-col items-center justify-center py-16 px-4"
>
<div class="text-center max-w-md space-y-6">
<div class="flex justify-center">
<div class="relative">
<div class="absolute inset-0 animate-ping opacity-20">
<div class="w-32 h-32 rounded-full bg-primary/20"></div>
</div>
<div class="relative p-8 rounded-full bg-linear-to-br from-primary/20 to-primary/5">
<ArrowRightLeft class="h-16 w-16 text-primary" />
</div>
</div>
</div>
<div class="space-y-3">
<h3 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ t('serverProxy.noProxiesTitle') }}
</h3>
<p class="text-sm sm:text-base text-muted-foreground">
{{ t('serverProxy.noProxiesDescription') }}
</p>
</div>
<Button
v-if="canManageProxy && proxyEnabled"
size="lg"
class="gap-2 shadow-lg"
:disabled="proxies.length >= settingsStore.serverProxyMaxPerServer"
@click="openCreateDrawer"
>
<Plus class="h-5 w-5" />
{{ t('serverProxy.createProxy') }}
</Button>
</div>
</div>
<!-- Plugin Widgets: Before Proxies List -->
<WidgetRenderer
v-if="!loading && proxies.length > 0 && widgetsBeforeTable.length > 0"
:widgets="widgetsBeforeTable"
/>
<!-- Proxies List -->
<Card v-if="!loading && proxies.length > 0" class="border-2 hover:border-primary/50 transition-colors">
<CardHeader>
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<ArrowRightLeft class="h-5 w-5 text-primary" />
</div>
<div class="flex-1">
<CardTitle class="text-lg">{{ t('serverProxy.proxiesTitle') }}</CardTitle>
<CardDescription class="text-sm">
{{ t('serverProxy.proxiesDescription') }}
</CardDescription>
</div>
<Badge variant="secondary" class="text-xs">
{{ proxies.length }}
{{ proxies.length === 1 ? 'proxy' : 'proxies' }}
</Badge>
</div>
</CardHeader>
<CardContent>
<div class="space-y-3">
<div
v-for="proxy in proxies"
:key="proxy.id"
class="group relative rounded-lg border-2 bg-card p-4 transition-all hover:border-primary/50 hover:shadow-md"
>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div class="flex items-start gap-3 flex-1 min-w-0">
<div
class="h-10 w-10 rounded-lg flex items-center justify-center shrink-0"
:class="proxy.ssl ? 'bg-green-500/10' : 'bg-muted'"
>
<component
:is="proxy.ssl ? CheckCircle : ArrowRightLeft"
class="h-5 w-5"
:class="proxy.ssl ? 'text-green-500' : 'text-muted-foreground'"
/>
</div>
<div class="flex-1 min-w-0 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono font-semibold text-sm break-all">
{{ proxy.domain }}
</span>
<Badge v-if="proxy.ssl" variant="default" class="text-xs">
{{ getSslTypeLabel(proxy.use_lets_encrypt) }}
</Badge>
<Badge variant="secondary" class="text-xs font-mono">
{{ proxy.ip }}:{{ proxy.port }}
</Badge>
</div>
<div
class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"
>
<span>
{{ t('serverProxy.createdAt') }}: {{ formatDate(proxy.created_at) }}
</span>
<span v-if="proxy.use_lets_encrypt && proxy.client_email">
{{ t('serverProxy.email') }}: {{ proxy.client_email }}
</span>
</div>
</div>
</div>
<div v-if="canManageProxy" class="flex flex-wrap items-center gap-2">
<Button
variant="destructive"
size="sm"
class="flex items-center gap-2"
:disabled="deletingProxyId === proxy.id || loading"
@click="deleteProxy(proxy)"
>
<Trash2 class="h-3.5 w-3.5" />
<span class="hidden sm:inline">{{ t('common.delete') }}</span>
</Button>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Plugin Widgets: After Proxies List -->
<WidgetRenderer
v-if="!loading && proxies.length > 0 && widgetsAfterTable.length > 0"
:widgets="widgetsAfterTable"
/>
<WidgetRenderer v-if="widgetsBottomOfPage.length > 0" :widgets="widgetsBottomOfPage" />
<!-- Create/Edit Proxy Drawer -->
<Drawer
:open="drawerOpen"
@update:open="
(val) => {
if (!val) closeDrawer();
}
"
>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{{ t('serverProxy.createProxy') }}</DrawerTitle>
<DrawerDescription>
{{ t('serverProxy.drawerDescription') }}
</DrawerDescription>
</DrawerHeader>
<div class="px-4 pb-4 space-y-4 overflow-y-auto max-h-[calc(100vh-200px)]">
<form class="space-y-4" @submit.prevent="saveProxy">
<!-- Domain -->
<div class="space-y-2">
<Label for="domain">{{ t('serverProxy.domain') }}</Label>
<Input
id="domain"
v-model="form.domain"
:placeholder="t('serverProxy.domainPlaceholder')"
:disabled="saving"
/>
<p v-if="errors.domain" class="text-sm text-destructive">{{ errors.domain }}</p>
<p class="text-xs text-muted-foreground">{{ t('serverProxy.domainHelp') }}</p>
</div>
<!-- Port -->
<div class="space-y-2">
<Label for="port">{{ t('serverProxy.port') }}</Label>
<Select v-model="form.port" :disabled="saving || loadingAllocations">
<SelectTrigger>
<SelectValue :placeholder="t('serverProxy.portPlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="allocation in allocations"
:key="allocation.id"
:value="String(allocation.port)"
>
{{ allocation.ip }}:{{ allocation.port }}
<span
v-if="allocation.is_primary"
class="ml-2 text-xs text-muted-foreground"
>
({{ t('serverAllocations.primary') }})
</span>
</SelectItem>
</SelectContent>
</Select>
<p v-if="errors.port" class="text-sm text-destructive">{{ errors.port }}</p>
<p class="text-xs text-muted-foreground">{{ t('serverProxy.portHelp') }}</p>
</div>
<!-- SSL Toggle -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label for="ssl">{{ t('serverProxy.ssl') }}</Label>
<div class="flex items-center gap-3">
<Button
type="button"
:variant="sslButtonVariant"
size="sm"
:disabled="saving"
class="min-w-[80px]"
@click="toggleSsl"
>
{{ sslButtonText }}
</Button>
</div>
</div>
<p class="text-xs text-muted-foreground">{{ t('serverProxy.sslHelp') }}</p>
</div>
<!-- DNS Instructions (shown when SSL/Let's Encrypt is enabled) -->
<div v-if="form.ssl && form.use_lets_encrypt" class="space-y-4">
<Card
class="border-2 border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-950/20"
>
<CardHeader class="pb-3">
<div class="flex items-center gap-3">
<div
class="h-10 w-10 rounded-lg bg-blue-500/20 flex items-center justify-center shrink-0"
>
<Network class="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div class="flex-1">
<CardTitle class="text-base text-blue-900 dark:text-blue-100">
{{ t('serverProxy.dnsVerificationRequired') }}
</CardTitle>
<CardDescription class="text-blue-700 dark:text-blue-300 mt-1">
{{ t('serverProxy.dnsVerificationDescription') }}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<!-- DNS Records Display -->
<div v-if="targetIp" class="space-y-3">
<div class="text-sm font-medium text-foreground">
{{ t('serverProxy.dnsRecordA') }}
</div>
<div class="rounded-lg border-2 bg-card p-4 space-y-3">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="space-y-1.5">
<Label class="text-xs font-medium text-muted-foreground">
{{ t('serverProxy.dnsRecordType') }}
</Label>
<div
class="flex items-center gap-2 px-3 py-2 rounded-md bg-muted font-mono text-sm"
>
<Badge variant="secondary" class="font-mono">A</Badge>
</div>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-medium text-muted-foreground">
{{ t('serverProxy.dnsRecordName') }}
</Label>
<div
class="px-3 py-2 rounded-md bg-muted font-mono text-sm break-all"
>
<template v-if="form.domain.trim()">{{
form.domain.trim()
}}</template>
<template v-else>{{ getYourDomainPlaceholder() }}</template>
</div>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-medium text-muted-foreground">
{{ t('serverProxy.dnsRecordValue') }}
</Label>
<div
class="px-3 py-2 rounded-md bg-muted font-mono text-sm break-all"
>
{{ targetIp }}
</div>
</div>
</div>
</div>
</div>
<!-- Verification Status -->
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 pt-2">
<Button
type="button"
:disabled="!form.domain.trim() || !form.port || verifyingDns || saving"
:variant="dnsVerified ? 'default' : 'default'"
class="shrink-0"
@click="verifyDns"
>
<Network v-if="!verifyingDns && !dnsVerified" class="h-4 w-4 mr-2" />
<CheckCircle
v-else-if="dnsVerified"
class="h-4 w-4 mr-2 text-green-500"
/>
<span v-if="verifyingDns" class="flex items-center gap-2">
<span
class="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin"
></span>
{{ t('serverProxy.verifyingDns') }}
</span>
<span v-else-if="dnsVerified">
{{ t('serverProxy.dnsVerified') }}
</span>
<span v-else>
{{ t('serverProxy.verifyDns') }}
</span>
</Button>
<div class="flex-1 min-w-0">
<Alert
v-if="dnsVerificationError"
variant="destructive"
class="border-2"
>
<AlertTriangle class="h-4 w-4" />
<AlertDescription class="text-sm">
{{ dnsVerificationError }}
</AlertDescription>
</Alert>
<Alert
v-else-if="dnsVerified"
class="border-2 border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-950/30"
>
<CheckCircle class="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertDescription
class="text-sm text-green-800 dark:text-green-200"
>
{{ t('serverProxy.dnsVerificationSuccess') }}
</AlertDescription>
</Alert>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- SSL Options (shown when SSL is enabled) -->
<div v-if="form.ssl" class="space-y-4 border-l-2 border-primary/20 pl-4">
<!-- Use Let's Encrypt -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label for="use_lets_encrypt">{{ getUseLetsEncryptLabel() }}</Label>
<div class="flex items-center gap-3">
<Button
type="button"
:variant="letsEncryptButtonVariant"
size="sm"
:disabled="saving"
class="min-w-[80px]"
@click="toggleUseLetsEncrypt"
>
{{ letsEncryptButtonText }}
</Button>
</div>
</div>
<p class="text-xs text-muted-foreground">
{{ getUseLetsEncryptHelp() }}
</p>
</div>
<!-- Let's Encrypt Email (shown when using Let's Encrypt) -->
<div v-if="form.use_lets_encrypt" class="space-y-2">
<Label for="client_email">{{ t('serverProxy.clientEmail') }}</Label>
<Input
id="client_email"
v-model="form.client_email"
type="email"
:placeholder="t('serverProxy.clientEmailPlaceholder')"
:disabled="saving"
/>
<p v-if="errors.client_email" class="text-sm text-destructive">
{{ errors.client_email }}
</p>
<p class="text-xs text-muted-foreground">{{ t('serverProxy.clientEmailHelp') }}</p>
</div>
<!-- Custom SSL Certificate (shown when NOT using Let's Encrypt) -->
<div v-if="!form.use_lets_encrypt" class="space-y-4">
<div class="space-y-2">
<Label for="ssl_cert">{{ t('serverProxy.sslCert') }}</Label>
<Textarea
id="ssl_cert"
v-model="form.ssl_cert"
:placeholder="t('serverProxy.sslCertPlaceholder')"
:disabled="saving"
rows="6"
class="font-mono text-xs"
/>
<p v-if="errors.ssl_cert" class="text-sm text-destructive">
{{ errors.ssl_cert }}
</p>
<p class="text-xs text-muted-foreground">{{ t('serverProxy.sslCertHelp') }}</p>
</div>
<div class="space-y-2">
<Label for="ssl_key">{{ t('serverProxy.sslKey') }}</Label>
<Textarea
id="ssl_key"
v-model="form.ssl_key"
:placeholder="t('serverProxy.sslKeyPlaceholder')"
:disabled="saving"
rows="6"
class="font-mono text-xs"
/>
<p v-if="errors.ssl_key" class="text-sm text-destructive">
{{ errors.ssl_key }}
</p>
<p class="text-xs text-muted-foreground">{{ t('serverProxy.sslKeyHelp') }}</p>
</div>
</div>
</div>
<!-- Error Message -->
<Alert v-if="formError" variant="destructive" class="border-2">
<AlertTitle>{{ t('serverProxy.errorTitle') }}</AlertTitle>
<AlertDescription>{{ formError }}</AlertDescription>
</Alert>
</form>
</div>
<DrawerFooter>
<Button variant="outline" :disabled="saving" @click="closeDrawer">
{{ t('common.cancel') }}
</Button>
<Button :disabled="saving || loadingAllocations" @click="saveProxy">
<span v-if="saving">{{ t('common.saving') }}</span>
<span v-else>{{ t('serverProxy.createProxy') }}</span>
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div>
</DashboardLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
import DashboardLayout from '@/layouts/DashboardLayout.vue';
import type { BreadcrumbEntry } from '@/layouts/DashboardLayout.vue';
import { useToast } from 'vue-toastification';
import { useSessionStore } from '@/stores/session';
import { useSettingsStore } from '@/stores/settings';
import WidgetRenderer from '@/components/plugins/WidgetRenderer.vue';
import { usePluginWidgets, getWidgets } from '@/composables/usePluginWidgets';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer';
import { Info, ArrowRightLeft, Plus, Network, CheckCircle, AlertTriangle, Trash2, RefreshCw } from 'lucide-vue-next';
const route = useRoute();
const { t } = useI18n();
const toast = useToast();
const sessionStore = useSessionStore();
const settingsStore = useSettingsStore();
const serverUuid = computed(() => route.params.uuidShort as string);
// Check if proxy management is enabled
const proxyEnabled = computed(() => settingsStore.serverAllowUserMadeProxy);
interface ServerInfo {
id: number;
name: string;
uuid: string;
}
interface ServerAllocation {
id: number;
node_id: number;
ip: string;
port: number;
ip_alias?: string;
notes?: string;
is_primary?: boolean;
}
const serverInfo = ref<ServerInfo | null>(null);
const allocations = ref<ServerAllocation[]>([]);
const loadingAllocations = ref<boolean>(false);
const proxies = ref<Proxy[]>([]);
const loading = ref<boolean>(false);
const deletingProxyId = ref<number | null>(null);
// Plugin widgets
const { fetchWidgets: fetchPluginWidgets } = usePluginWidgets('server-proxy');
const widgetsTopOfPage = computed(() => getWidgets('server-proxy', 'top-of-page'));
const widgetsAfterHeader = computed(() => getWidgets('server-proxy', 'after-header'));
const widgetsBeforeTable = computed(() => getWidgets('server-proxy', 'before-proxies-list'));
const widgetsAfterTable = computed(() => getWidgets('server-proxy', 'after-proxies-list'));
const widgetsBottomOfPage = computed(() => getWidgets('server-proxy', 'bottom-of-page'));
interface Proxy {
id: number;
server_id: number;
domain: string;
ip: string;
port: number;
ssl: boolean;
use_lets_encrypt: boolean;
client_email?: string | null;
ssl_cert?: string | null;
ssl_key?: string | null;
created_at: string;
updated_at: string;
}
const breadcrumbs = computed<BreadcrumbEntry[]>(() => [
{ text: t('common.dashboard'), href: '/dashboard' },
{ text: t('common.servers'), href: '/dashboard' },
{ text: serverInfo.value?.name || t('common.server'), href: `/server/${route.params.uuidShort}` },
{ text: t('serverProxy.title'), isCurrent: true, href: `/server/${route.params.uuidShort}/proxy` },
]);
function getAxiosErrorMessage(err: unknown, fallback: string): string {
return axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : fallback;
}
const drawerOpen = ref<boolean>(false);
const saving = ref<boolean>(false);
const formError = ref<string | null>(null);
const dnsVerified = ref<boolean>(false);
const verifyingDns = ref<boolean>(false);
const dnsVerificationError = ref<string | null>(null);
const targetIp = ref<string | null>(null);
const form = reactive<{
domain: string;
port: string;
ssl: boolean;
use_lets_encrypt: boolean;
client_email: string;
ssl_cert: string;
ssl_key: string;
}>({
domain: '',
port: '',
ssl: false,
use_lets_encrypt: false,
client_email: '',
ssl_cert: '',
ssl_key: '',
});
const errors = reactive<{
domain: string;
port: string;
client_email: string;
ssl_cert: string;
ssl_key: string;
}>({
domain: '',
port: '',
client_email: '',
ssl_cert: '',
ssl_key: '',
});
const canManageProxy = computed<boolean>(() => {
return sessionStore.hasPermission('proxy.manage');
});
const sslButtonText = computed<string>(() => {
return form.ssl ? 'On' : 'Off';
});
const sslButtonVariant = computed<'default' | 'outline'>(() => {
return form.ssl ? 'default' : 'outline';
});
const letsEncryptButtonText = computed<string>(() => {
return form.use_lets_encrypt ? 'On' : 'Off';
});
const letsEncryptButtonVariant = computed<'default' | 'outline'>(() => {
return form.use_lets_encrypt ? 'default' : 'outline';
});
async function fetchServerAllocations(): Promise<void> {
if (!serverUuid.value) return;
loadingAllocations.value = true;
try {
const { data } = await axios.get(`/api/user/servers/${serverUuid.value}/allocations`);
if (!data.success) {
toast.error(data.message || t('serverAllocations.failedToFetch'));
return;
}
serverInfo.value = {
id: data.data.server.id,
name: data.data.server.name,
uuid: data.data.server.uuid,
};
allocations.value = data.data.allocations ?? [];
// Set default port from primary allocation
const primary = allocations.value.find((a) => a.is_primary);
if (primary) {
form.port = String(primary.port);
} else if (allocations.value.length > 0) {
const firstAllocation = allocations.value[0];
if (firstAllocation) {
form.port = String(firstAllocation.port);
}
}
} catch (error) {
console.error('Failed to fetch server allocations for proxy:', error);
toast.error(getAxiosErrorMessage(error, t('serverAllocations.failedToFetch')));
} finally {
loadingAllocations.value = false;
}
}
function resetForm(): void {
form.domain = '';
form.port = '';
form.ssl = false;
form.use_lets_encrypt = false;
form.client_email = '';
form.ssl_cert = '';
form.ssl_key = '';
formError.value = null;
errors.domain = '';
errors.port = '';
errors.client_email = '';
errors.ssl_cert = '';
errors.ssl_key = '';
dnsVerified.value = false;
dnsVerificationError.value = null;
targetIp.value = null;
}
async function calculateTargetIp(): Promise<void> {
if (!form.domain.trim() || !form.port) {
targetIp.value = null;
return;
}
// Get allocation to determine target IP
const portNum = parseInt(form.port, 10);
const allocation = allocations.value.find((a) => a.port === portNum);
if (!allocation) {
targetIp.value = null;
return;
}
// For now, we'll get the target IP from the verify endpoint
// But we can also calculate it here if we have node info
// For simplicity, we'll fetch it during verification
targetIp.value = allocation.ip; // Will be updated during verification if internal IP
}
async function verifyDns(): Promise<void> {
if (!form.domain.trim() || !form.port) {
dnsVerificationError.value = t('serverProxy.domainAndPortRequired');
return;
}
verifyingDns.value = true;
dnsVerificationError.value = null;
dnsVerified.value = false;
try {
const { data } = await axios.post(`/api/user/servers/${serverUuid.value}/proxy/verify-dns`, {
domain: form.domain.trim(),
port: form.port.trim(),
});
if (data.success && data.data) {
dnsVerified.value = data.data.verified === true;
targetIp.value = data.data.expected_ip || null;
if (data.data.verified) {
dnsVerificationError.value = null;
toast.success(data.data.message || t('serverProxy.dnsVerificationSuccess'));
} else {
dnsVerificationError.value = data.data.message || t('serverProxy.dnsVerificationFailed');
}
} else {
dnsVerified.value = false;
dnsVerificationError.value = data.message || t('serverProxy.dnsVerificationFailed');
}
} catch (error) {
console.error('DNS verification failed:', error);
dnsVerified.value = false;
dnsVerificationError.value = getAxiosErrorMessage(error, t('serverProxy.dnsVerificationFailed'));
} finally {
verifyingDns.value = false;
}
}
function openCreateDrawer(): void {
resetForm();
// Set default port from allocations
if (allocations.value.length > 0) {
const primary = allocations.value.find((a) => a.is_primary);
if (primary) {
form.port = String(primary.port);
} else {
const firstAllocation = allocations.value[0];
if (firstAllocation) {
form.port = String(firstAllocation.port);
}
}
}
drawerOpen.value = true;
}
function closeDrawer(): void {
drawerOpen.value = false;
}
function toggleSsl(): void {
form.ssl = !form.ssl;
}
function toggleUseLetsEncrypt(): void {
form.use_lets_encrypt = !form.use_lets_encrypt;
}
function getSslTypeLabel(useLetsEncrypt: boolean): string {
return useLetsEncrypt ? t('serverProxy.useLetsEncrypt') : 'SSL';
}
function getUseLetsEncryptLabel(): string {
return t('serverProxy.useLetsEncrypt');
}
function getUseLetsEncryptHelp(): string {
return t('serverProxy.useLetsEncryptHelp');
}
function getYourDomainPlaceholder(): string {
return t('serverProxy.yourDomain');
}
function validateForm(): boolean {
let valid = true;
errors.domain = '';
errors.port = '';
errors.client_email = '';
errors.ssl_cert = '';
errors.ssl_key = '';
formError.value = null;
// Validate domain - required and must be valid format
const domainTrimmed = form.domain?.trim() || '';
if (!domainTrimmed) {
errors.domain = t('serverProxy.validation.domainRequired');
valid = false;
} else {
// More comprehensive domain validation
// Must have at least one dot (e.g., example.com)
if (!domainTrimmed.includes('.')) {
errors.domain = t('serverProxy.validation.domainInvalid');
valid = false;
} else {
// Check domain length (max 253 chars total, each label max 63)
if (domainTrimmed.length > 253) {
errors.domain = t('serverProxy.validation.domainInvalid');
valid = false;
} else {
// Validate domain format: letters, numbers, dots, hyphens
// Must start and end with alphanumeric, labels separated by dots
const domainRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
if (!domainRegex.test(domainTrimmed)) {
errors.domain = t('serverProxy.validation.domainInvalid');
valid = false;
} else {
// Check each label length (max 63 chars)
const labels = domainTrimmed.split('.');
for (const label of labels) {
if (label.length > 63 || label.length === 0) {
errors.domain = t('serverProxy.validation.domainInvalid');
valid = false;
break;
}
}
}
}
}
}
// Validate port - required and must be valid
const portTrimmed = form.port?.trim() || '';
if (!portTrimmed) {
errors.port = t('serverProxy.validation.portRequired');
valid = false;
} else {
const portNum = parseInt(portTrimmed, 10);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
errors.port = t('serverProxy.validation.portInvalid');
valid = false;
} else {
// Ensure port belongs to an allocation
const hasMatchingAllocation = allocations.value.some((a) => a.port === portNum);
if (!hasMatchingAllocation) {
errors.port = t('serverProxy.validation.portNotAllocated');
valid = false;
}
}
}
// SSL validation
if (form.ssl === true) {
if (form.use_lets_encrypt === true) {
// Let's Encrypt requires email
const emailTrimmed = form.client_email?.trim() || '';
if (!emailTrimmed) {
errors.client_email = t('serverProxy.validation.emailRequired');
valid = false;
} else {
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(emailTrimmed)) {
errors.client_email = t('serverProxy.validation.emailInvalid');
valid = false;
}
}
} else {
// Custom SSL requires both cert and key
const certTrimmed = form.ssl_cert?.trim() || '';
const keyTrimmed = form.ssl_key?.trim() || '';
if (!certTrimmed) {
errors.ssl_cert = t('serverProxy.validation.sslCertRequired');
valid = false;
}
if (!keyTrimmed) {
errors.ssl_key = t('serverProxy.validation.sslKeyRequired');
valid = false;
}
}
}
return valid;
}
function getErrorMessage(err: unknown): string {
if (axios.isAxiosError(err)) {
const responseData = err.response?.data;
if (responseData) {
// Try to extract the actual error message from nested structures
if (responseData.error_message) {
return responseData.error_message;
}
if (responseData.message) {
// Check if message contains nested error info
const message = responseData.message;
// Try to extract error from Response: {...} pattern
const responseMatch = message.match(/Response:\s*\{[^}]*"error":\s*"([^"]+)"/);
if (responseMatch && responseMatch[1]) {
return responseMatch[1];
}
// Try to extract error from error field in nested structure
if (message.includes('Failed to request certificate')) {
const certErrorMatch = message.match(/Failed to request certificate[^:]*:\s*(.+?)(?:\n|$)/);
if (certErrorMatch && certErrorMatch[1]) {
return certErrorMatch[1].trim();
}
return 'Failed to request certificate. Please check your domain DNS configuration and ensure port 80/443 is accessible.';
}
return message;
}
if (responseData.error) {
return responseData.error;
}
// Check errors array
if (Array.isArray(responseData.errors) && responseData.errors.length > 0) {
const firstError = responseData.errors[0];
if (firstError.detail) {
return firstError.detail;
}
if (firstError.message) {
return firstError.message;
}
}
}
return err.message || t('serverProxy.unknownError');
}
if (err instanceof Error) {
return err.message;
}
return t('serverProxy.unknownError');
}
async function saveProxy(): Promise<void> {
if (!serverUuid.value || !proxyEnabled.value || !validateForm()) {
return;
}
// Require DNS verification for Let's Encrypt
if (form.ssl && form.use_lets_encrypt && !dnsVerified.value) {
toast.error(t('serverProxy.dnsVerificationRequiredError'));
return;
}
saving.value = true;
try {
await axios.post(`/api/user/servers/${serverUuid.value}/proxy/create`, {
domain: form.domain.trim(),
port: form.port.trim(),
ssl: form.ssl,
use_lets_encrypt: form.use_lets_encrypt,
client_email: form.use_lets_encrypt ? form.client_email.trim() : '',
ssl_cert: form.use_lets_encrypt ? '' : form.ssl_cert.trim(),
ssl_key: form.use_lets_encrypt ? '' : form.ssl_key.trim(),
});
toast.success(t('serverProxy.createSuccess'));
closeDrawer();
await fetchProxies();
} catch (error) {
console.error('Failed to create proxy:', error);
formError.value = getErrorMessage(error);
toast.error(formError.value);
} finally {
saving.value = false;
}
}
async function fetchProxies(): Promise<void> {
if (!serverUuid.value) return;
loading.value = true;
try {
const { data } = await axios.get(`/api/user/servers/${serverUuid.value}/proxy`);
if (!data.success) {
toast.error(data.message || t('serverProxy.failedToFetch'));
return;
}
proxies.value = data.data.proxies ?? [];
} catch (error) {
console.error('Failed to fetch proxies:', error);
toast.error(getAxiosErrorMessage(error, t('serverProxy.failedToFetch')));
} finally {
loading.value = false;
}
}
function refresh(): void {
void fetchProxies();
}
async function deleteProxy(proxy: Proxy): Promise<void> {
if (!serverUuid.value) return;
deletingProxyId.value = proxy.id;
try {
await axios.post(`/api/user/servers/${serverUuid.value}/proxy/delete`, {
id: proxy.id,
});
toast.success(t('serverProxy.deleteSuccess'));
await fetchProxies();
} catch (error) {
console.error('Failed to delete proxy:', error);
toast.error(getAxiosErrorMessage(error, t('serverProxy.deleteFailed')));
} finally {
deletingProxyId.value = null;
}
}
function formatDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date);
}
// Watch for domain/port changes to reset DNS verification
watch([() => form.domain, () => form.port], () => {
if (form.ssl && form.use_lets_encrypt) {
dnsVerified.value = false;
dnsVerificationError.value = null;
void calculateTargetIp();
}
});
// Watch for SSL/Let's Encrypt changes
watch([() => form.ssl, () => form.use_lets_encrypt], () => {
if (!form.ssl || !form.use_lets_encrypt) {
dnsVerified.value = false;
dnsVerificationError.value = null;
targetIp.value = null;
} else {
void calculateTargetIp();
}
});
onMounted(async () => {
// Fetch plugin widgets
await fetchPluginWidgets();
// Fetch settings first to check if proxy is enabled
await settingsStore.fetchSettings();
// Only fetch data if the feature is enabled
if (settingsStore.serverAllowUserMadeProxy) {
await Promise.all([fetchServerAllocations(), fetchProxies()]);
}
});
</script>Than i get error ServerProxy.vue:817 SyntaxError: 10 (at message-compiler.mjs:77:23)
at ue (message-compiler.mjs:77:23)
at d (message-compiler.mjs:201:25)
at me (message-compiler.mjs:772:21)
at ve (message-compiler.mjs:786:20)
at Object.ye [as nextToken] (message-compiler.mjs:825:16)
at f (message-compiler.mjs:959:31)
at p (message-compiler.mjs:1059:36)
at h (message-compiler.mjs:1099:25)
at Object.g [as parse] (message-compiler.mjs:1114:21)
at Ie (message-compiler.mjs:1513:24)
Dr @ runtime-core.esm-bundler.js:275
Er @ runtime-core.esm-bundler.js:255
xa @ runtime-core.esm-bundler.js:4531
s @ runtime-core.esm-bundler.js:6112
run @ reactivity.esm-bundler.js:237
runIfDirty @ reactivity.esm-bundler.js:275
wr @ runtime-core.esm-bundler.js:199
Fr @ runtime-core.esm-bundler.js:408
Promise.then
jr @ runtime-core.esm-bundler.js:322
Ar @ runtime-core.esm-bundler.js:317
s.scheduler @ runtime-core.esm-bundler.js:4197
Sn.u.scheduler @ reactivity.esm-bundler.js:1900
trigger @ reactivity.esm-bundler.js:265
St @ reactivity.esm-bundler.js:323
Ft @ reactivity.esm-bundler.js:741
set @ reactivity.esm-bundler.js:1059
Tn @ ServerProxy.vue:817
wr @ runtime-core.esm-bundler.js:199
Tr @ runtime-core.esm-bundler.js:206
n @ runtime-dom.esm-bundler.js:730
When i build to production and i click the button for let's encrypt!
System Info
System:
OS: Linux 6.17 Debian GNU/Linux 12 (bookworm) 12 (bookworm)
CPU: (8) x64 AMD Ryzen 7 5700G with Radeon Graphics
Memory: 19.40 GB / 24.00 GB
Container: Yes
Shell: 5.2.15 - /bin/bash
Binaries:
Node: 24.6.0 - /root/.nvm/versions/node/v24.6.0/bin/node
Yarn: 1.22.22 - /root/.nvm/versions/node/v24.6.0/bin/yarn
npm: 11.6.0 - /root/.nvm/versions/node/v24.6.0/bin/npm
pnpm: 10.25.0 - /root/.local/share/pnpm/pnpm
bun: 1.2.19 - /root/.bun/bin/bun
npmPackages:
@vitejs/plugin-vue: ^6.0.3 => 6.0.3
@vue/tsconfig: ^0.8.1 => 0.8.1
rolldown-vite: 7.2.11
vite-plugin-vue-devtools: ^8.0.5 => 8.0.5
vue: ^3.5.25 => 3.5.25
vue-animejs: ^2.1.1 => 2.1.1
vue-chartjs: ^5.3.3 => 5.3.3
vue-eslint-parser: ^10.2.0 => 10.2.0
vue-i18n: ^11.2.2 => 11.2.2
vue-qrcode: ^2.2.2 => 2.2.2
vue-router: ^4.6.4 => 4.6.4
vue-toastification: 2.0.0-rc.5 => 2.0.0-rc.5
vue-tsc: ^3.1.8 => 3.1.8
vue-turnstile: ^1.0.11 => 1.0.11
vue3-ace-editor: ^2.2.4 => 2.2.4
vuedraggable: ^4.1.0 => 4.1.0Screenshot
Additional context
No
Validations
- Read the Contributing Guidelines
- Read the Documentation
- Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
- Check that this is a concrete bug. For Q&A open a GitHub Discussions