Feat: Initial Setup through env vars (#1736)

* initial support for initial setup

* improve setup

* improve mobile view

* move base admin route

* admin panel mobile view

* set initial host and port

* add docs

* properly setup everything, use for dev env

* change userconfig and interface port on setup, note users afterwards
This commit is contained in:
Bernd Storath
2025-03-13 11:28:05 +01:00
committed by GitHub
parent 4890bb28e5
commit 86bdbe4c3d
26 changed files with 277 additions and 129 deletions

View File

@@ -1,14 +1,17 @@
<template>
<TooltipProvider>
<TooltipRoot>
<TooltipRoot :open="open" @update:open="open = $event">
<TooltipTrigger
class="inline-flex h-8 w-8 items-center justify-center rounded-full text-gray-400 outline-none focus:shadow-sm focus:shadow-black"
class="mx-2 inline-flex h-4 w-4 items-center justify-center rounded-full text-gray-400 outline-none focus:shadow-sm focus:shadow-black"
as-child
>
<slot />
<button @click="open = !open">
<slot />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
class="select-none rounded bg-gray-600 px-3 py-2 text-sm leading-none text-white shadow-lg will-change-[transform,opacity]"
class="select-none whitespace-pre-line rounded bg-gray-600 px-3 py-2 text-sm leading-none text-white shadow-lg will-change-[transform,opacity]"
:side-offset="5"
>
{{ text }}
@@ -21,4 +24,6 @@
<script lang="ts" setup>
defineProps<{ text: string }>();
const open = ref(false);
</script>

View File

@@ -1,5 +1,5 @@
<template>
<section class="grid grid-cols-1 gap-4 md:grid-cols-2">
<section class="grid grid-cols-2 gap-4">
<slot />
<Separator
decorative

View File

@@ -1,5 +1,5 @@
<template>
<NuxtLink to="/" class="mb-4 flex-grow self-start">
<NuxtLink to="/" class="mb-4">
<h1 class="text-4xl font-medium dark:text-neutral-200">
<img
src="/logo.png"

View File

@@ -1,6 +1,10 @@
<template>
<div
v-if="globalStore.release?.updateAvailable"
v-if="
globalStore.release?.updateAvailable &&
authStore.userData &&
hasPermissions(authStore.userData, 'admin', 'any')
"
class="font-small mb-10 rounded-md bg-red-800 p-4 text-sm text-white shadow-lg dark:bg-red-100 dark:text-red-600"
:title="`v${globalStore.release.currentRelease} → v${globalStore.release.latestRelease.version}`"
>
@@ -23,6 +27,5 @@
<script lang="ts" setup>
const globalStore = useGlobalStore();
// TODO: only show this to admins
const authStore = useAuthStore();
</script>

View File

@@ -1,23 +1,23 @@
<template>
<div>
<header class="container mx-auto mt-4 max-w-3xl px-3 xs:mt-6 md:px-0">
<header class="mx-auto mt-4 flex max-w-3xl flex-col justify-center">
<div
class="mb-5"
class="mb-5 w-full"
:class="
loggedIn
? 'flex flex-auto flex-col-reverse items-center gap-3 xxs:flex-row'
? 'flex flex-col items-center justify-between sm:flex-row'
: 'flex justify-end'
"
>
<HeaderLogo v-if="loggedIn" />
<div class="flex grow-0 items-center gap-3 self-end xxs:self-center">
<div class="flex flex-row gap-3">
<HeaderLangSelector />
<HeaderThemeSwitch />
<HeaderChartToggle v-if="loggedIn" />
<UiUserMenu v-if="loggedIn" />
</div>
</div>
<HeaderUpdate class="mt-5" />
<HeaderUpdate class="mt-4" />
</header>
<slot />
<UiFooter />

View File

@@ -11,8 +11,8 @@
</header>
<main>
<Panel>
<PanelBody class="mx-auto mt-10 p-4 md:w-[70%] lg:w-[60%]">
<h2 class="mb-16 mt-8 text-3xl font-medium">
<PanelBody class="m-4 mx-auto mt-10 md:w-[70%] lg:w-[60%]">
<h2 class="mb-16 mt-8 text-center text-3xl font-medium">
{{ $t('setup.welcome') }}
</h2>

View File

@@ -1,8 +1,8 @@
<template>
<div>
<div class="container mx-auto p-4">
<div class="flex">
<div class="mr-4 w-64 rounded-lg bg-white p-4 dark:bg-neutral-700">
<div class="flex flex-col gap-4 lg:flex-row">
<div class="rounded-lg bg-white p-4 lg:w-64 dark:bg-neutral-700">
<NuxtLink to="/admin">
<h2 class="mb-4 text-xl font-bold dark:text-neutral-200">
{{ t('pages.admin.panel') }}
@@ -13,6 +13,7 @@
v-for="(item, index) in menuItems"
:key="index"
:to="`/admin/${item.id}`"
active-class="bg-red-800 rounded"
>
<BaseButton
as="span"
@@ -27,7 +28,7 @@
<div
class="flex-1 rounded-lg bg-white p-6 dark:bg-neutral-700 dark:text-neutral-200"
>
<h1 class="mb-6 text-3xl font-bold">{{ activeMenuItem?.name }}</h1>
<h1 class="mb-6 text-3xl font-bold">{{ activeMenuItem.name }}</h1>
<NuxtPage />
</div>
</div>
@@ -44,13 +45,17 @@ const { t } = useI18n();
const route = useRoute();
const menuItems = [
{ id: '', name: t('pages.admin.general') },
{ id: 'general', name: t('pages.admin.general') },
{ id: 'config', name: t('pages.admin.config') },
{ id: 'interface', name: t('pages.admin.interface') },
{ id: 'hooks', name: t('pages.admin.hooks') },
];
const defaultItem = { id: '', name: t('pages.admin.panel') };
const activeMenuItem = computed(() => {
return menuItems.find((item) => route.path === `/admin/${item.id}`);
return (
menuItems.find((item) => route.path === `/admin/${item.id}`) ?? defaultItem
);
});
</script>

View File

@@ -0,0 +1,64 @@
<template>
<main v-if="data">
<FormElement @submit.prevent="submit">
<FormGroup>
<FormNumberField
id="session"
v-model="data.sessionTimeout"
:label="$t('admin.general.sessionTimeout')"
:description="$t('admin.general.sessionTimeoutDesc')"
/>
</FormGroup>
<FormGroup>
<FormHeading>{{ $t('admin.general.metrics') }}</FormHeading>
<FormNullTextField
id="password"
v-model="data.metricsPassword"
:label="$t('admin.general.metricsPassword')"
:description="$t('admin.general.metricsPasswordDesc')"
/>
<FormSwitchField
id="prometheus"
v-model="data.metricsPrometheus"
:label="$t('admin.general.prometheus')"
:description="$t('admin.general.prometheusDesc')"
/>
<FormSwitchField
id="json"
v-model="data.metricsJson"
:label="$t('admin.general.json')"
:description="$t('admin.general.jsonDesc')"
/>
</FormGroup>
<FormGroup>
<FormHeading>{{ $t('form.actions') }}</FormHeading>
<FormActionField type="submit" :label="$t('form.save')" />
<FormActionField :label="$t('form.revert')" @click="revert" />
</FormGroup>
</FormElement>
</main>
</template>
<script setup lang="ts">
const { data: _data, refresh } = await useFetch(`/api/admin/general`, {
method: 'get',
});
const data = toRef(_data.value);
const _submit = useSubmit(
`/api/admin/general`,
{
method: 'post',
},
{ revert }
);
function submit() {
return _submit(data.value);
}
async function revert() {
await refresh();
data.value = toRef(_data.value).value;
}
</script>

View File

@@ -1,64 +1,5 @@
<template>
<main v-if="data">
<FormElement @submit.prevent="submit">
<FormGroup>
<FormNumberField
id="session"
v-model="data.sessionTimeout"
:label="$t('admin.general.sessionTimeout')"
:description="$t('admin.general.sessionTimeoutDesc')"
/>
</FormGroup>
<FormGroup>
<FormHeading>{{ $t('admin.general.metrics') }}</FormHeading>
<FormNullTextField
id="password"
v-model="data.metricsPassword"
:label="$t('admin.general.metricsPassword')"
:description="$t('admin.general.metricsPasswordDesc')"
/>
<FormSwitchField
id="prometheus"
v-model="data.metricsPrometheus"
:label="$t('admin.general.prometheus')"
:description="$t('admin.general.prometheusDesc')"
/>
<FormSwitchField
id="json"
v-model="data.metricsJson"
:label="$t('admin.general.json')"
:description="$t('admin.general.jsonDesc')"
/>
</FormGroup>
<FormGroup>
<FormHeading>{{ $t('form.actions') }}</FormHeading>
<FormActionField type="submit" :label="$t('form.save')" />
<FormActionField :label="$t('form.revert')" @click="revert" />
</FormGroup>
</FormElement>
<main class="flex flex-col gap-3">
<p class="whitespace-pre-line">{{ $t('admin.introText') }}</p>
</main>
</template>
<script setup lang="ts">
const { data: _data, refresh } = await useFetch(`/api/admin/general`, {
method: 'get',
});
const data = toRef(_data.value);
const _submit = useSubmit(
`/api/admin/general`,
{
method: 'post',
},
{ revert }
);
function submit() {
return _submit(data.value);
}
async function revert() {
await refresh();
data.value = toRef(_data.value).value;
}
</script>

View File

@@ -54,6 +54,9 @@
</template>
<script setup lang="ts">
const authStore = useAuthStore();
authStore.update();
const authenticating = ref(false);
const remember = ref(false);
const username = ref<null | string>(null);

View File

@@ -1,9 +1,9 @@
<template>
<div>
<p class="px-8 pt-8 text-center text-2xl">
<div class="flex flex-col items-center">
<p class="px-8 text-center text-2xl">
{{ $t('setup.welcomeDesc') }}
</p>
<NuxtLink to="/setup/2">
<NuxtLink to="/setup/2" class="mt-8">
<BaseButton as="span">{{ $t('general.continue') }}</BaseButton>
</NuxtLink>
</div>

View File

@@ -1,9 +1,9 @@
<template>
<div>
<p class="p-8 text-center text-lg">
<p class="text-center text-lg">
{{ $t('setup.createAdminDesc') }}
</p>
<div class="flex flex-col gap-3">
<div class="mt-8 flex flex-col gap-3">
<div class="flex flex-col">
<FormNullTextField
id="username"
@@ -28,7 +28,7 @@
:label="$t('general.confirmPassword')"
/>
</div>
<div>
<div class="mt-4 flex justify-center">
<BaseButton @click="submit">{{ $t('setup.createAccount') }}</BaseButton>
</div>
</div>

View File

@@ -1,14 +1,18 @@
<template>
<div>
<p class="p-8 text-center text-lg">
<p class="text-center text-lg">
{{ $t('setup.existingSetup') }}
</p>
<div class="mb-8 flex justify-center">
<NuxtLink to="/setup/4">
<BaseButton as="span">{{ $t('general.no') }}</BaseButton>
<div class="mt-4 flex justify-center gap-3">
<NuxtLink to="/setup/4" class="w-20">
<BaseButton as="span" class="w-full justify-center">
{{ $t('general.no') }}
</BaseButton>
</NuxtLink>
<NuxtLink to="/setup/migrate">
<BaseButton as="span">{{ $t('general.yes') }}</BaseButton>
<NuxtLink to="/setup/migrate" class="w-20">
<BaseButton as="span" class="w-full justify-center">
{{ $t('general.yes') }}
</BaseButton>
</NuxtLink>
</div>
</div>

View File

@@ -1,21 +1,27 @@
<template>
<div>
<p class="p-8 text-center text-lg">
<p class="text-center text-lg">
{{ $t('setup.setupConfigDesc') }}
</p>
<div class="flex flex-col gap-3">
<div class="mt-8 flex flex-col gap-3">
<div class="flex flex-col">
<FormNullTextField
id="host"
v-model="host"
:label="$t('general.host')"
placeholder="vpn.example.com"
:description="$t('setup.hostDesc')"
/>
</div>
<div class="flex flex-col">
<FormNumberField id="port" v-model="port" :label="$t('general.port')" />
<FormNumberField
id="port"
v-model="port"
:label="$t('general.port')"
:description="$t('setup.portDesc')"
/>
</div>
<div>
<div class="mt-4 flex justify-center">
<BaseButton @click="submit">{{ $t('general.continue') }}</BaseButton>
</div>
</div>

View File

@@ -1,13 +1,15 @@
<template>
<div>
<p class="p-8 text-center text-lg">
<div class="flex flex-col items-center">
<p class="text-center text-lg">
{{ $t('setup.setupMigrationDesc') }}
</p>
<div>
<div class="mt-8 flex gap-3">
<Label for="migration">{{ $t('setup.migration') }}</Label>
<input id="migration" type="file" @change="onChangeFile" />
</div>
<BaseButton @click="submit">{{ $t('setup.upload') }}</BaseButton>
<div class="mt-4">
<BaseButton @click="submit">{{ $t('setup.upload') }}</BaseButton>
</div>
</div>
</template>

View File

@@ -1,7 +1,7 @@
<template>
<div>
<div class="flex flex-col items-center">
<p>{{ $t('setup.successful') }}</p>
<NuxtLink to="/login">
<NuxtLink to="/login" class="mt-4">
<BaseButton as="span">{{ $t('login.signIn') }}</BaseButton>
</NuxtLink>
</div>

View File

@@ -35,16 +35,18 @@
"confirmPassword": "Confirm Password"
},
"setup": {
"welcome": "Welcome to your first setup of wg-easy !",
"welcomeDesc": "You have found the easiest way to install and manage WireGuard on any Linux host!",
"welcome": "Welcome to your first setup of wg-easy",
"welcomeDesc": "You have found the easiest way to install and manage WireGuard on any Linux host",
"existingSetup": "Do you have an existing setup?",
"createAdminDesc": "Please first enter an admin username and a strong secure password. This information will be used to log in to your administration panel.",
"setupConfigDesc": "Please enter the host and port information. This will be used for the client configuration when setting up WireGuard on their devices.",
"setupMigrationDesc": "Please provide the backup file if you want to migrate your data from your previous wg-easy version to your new setup.",
"upload": "Upload",
"migration": "Restore the backup",
"migration": "Restore the backup:",
"createAccount": "Create Account",
"successful": "Setup successful"
"successful": "Setup successful",
"hostDesc": "Public hostname clients will connect to",
"portDesc": "Public UDP port clients will connect to and WireGuard will listen on"
},
"update": {
"updateAvailable": "There is an update available!",
@@ -141,7 +143,7 @@
"config": {
"connection": "Connection",
"hostDesc": "Public hostname clients will connect to (invalidates config)",
"portDesc": "Public UDP port clients will connect to (invalidates config)",
"portDesc": "Public UDP port clients will connect to (invalidates config, you probably want to change Interface Port too)",
"allowedIpsDesc": "Allowed IPs clients will use (global config)",
"dnsDesc": "DNS server clients will use (global config)",
"mtuDesc": "MTU clients will use (only for new clients)",
@@ -153,9 +155,10 @@
"device": "Device",
"deviceDesc": "Ethernet device the wireguard traffic should be forwarded through",
"mtuDesc": "MTU WireGuard will use",
"portDesc": "UDP Port WireGuard will listen on (could invalidate config)",
"portDesc": "UDP Port WireGuard will listen on (you probably want to change Config Port too)",
"changeCidr": "Change CIDR"
}
},
"introText": "Welcome to the admin panel.\n\nHere you can manage the general settings, the configuration, the interface settings and the hooks.\n\nStart by choosing one of the sections in the sidebar."
},
"zod": {
"generic": {

View File

@@ -28,7 +28,9 @@ export default defineNuxtConfig({
},
locales: [
{
// same as i18n.config.ts
code: 'en',
// BCP 47 language tag
language: 'en-US',
name: 'English',
},

View File

@@ -2,11 +2,10 @@ export default defineEventHandler(async (event) => {
const session = await useWGSession(event);
if (!session.data.userId) {
throw createError({
statusCode: 401,
statusMessage: 'Not logged in',
});
// not logged in
return null;
}
const user = await Database.users.get(session.data.userId);
if (!user) {
throw createError({

View File

@@ -1,6 +1,7 @@
import { eq, sql } from 'drizzle-orm';
import { userConfig } from './schema';
import type { UserConfigUpdateType } from './types';
import { wgInterface } from '#db/schema';
import type { DBType } from '#db/sqlite';
function createPreparedStatement(db: DBType) {
@@ -8,14 +9,6 @@ function createPreparedStatement(db: DBType) {
get: db.query.userConfig
.findFirst({ where: eq(userConfig.id, sql.placeholder('interface')) })
.prepare(),
updateHostPort: db
.update(userConfig)
.set({
host: sql.placeholder('host') as never as string,
port: sql.placeholder('port') as never as number,
})
.where(eq(userConfig.id, sql.placeholder('interface')))
.prepare(),
};
}
@@ -38,11 +31,26 @@ export class UserConfigService {
return userConfig;
}
// TODO: wrap ipv6 host in square brackets
/**
* sets host of user config
*
* sets port of user config and interface
*/
updateHostPort(host: string, port: number) {
return this.#statements.updateHostPort.execute({
interface: 'wg0',
host,
port,
return this.#db.transaction(async (tx) => {
await tx
.update(userConfig)
.set({ host, port })
.where(eq(userConfig.id, 'wg0'))
.execute();
await tx
.update(wgInterface)
.set({ port })
.where(eq(wgInterface.name, 'wg0'))
.execute();
});
}

View File

@@ -19,7 +19,13 @@ const db = drizzle({ client, schema });
export async function connect() {
await migrate();
return new DBService(db);
const dbService = new DBService(db);
if (WG_INITIAL_ENV.ENABLED) {
await initialSetup(dbService);
}
return dbService;
}
class DBService {
@@ -58,3 +64,47 @@ async function migrate() {
}
}
}
async function initialSetup(db: DBServiceType) {
const setup = await db.general.getSetupStep();
if (setup.done) {
DB_DEBUG('Setup already done. Skiping initial setup.');
return;
}
if (WG_INITIAL_ENV.IPV4_CIDR && WG_INITIAL_ENV.IPV6_CIDR) {
DB_DEBUG('Setting initial CIDR...');
await db.interfaces.updateCidr({
ipv4Cidr: WG_INITIAL_ENV.IPV4_CIDR,
ipv6Cidr: WG_INITIAL_ENV.IPV6_CIDR,
});
}
if (WG_INITIAL_ENV.DNS) {
DB_DEBUG('Setting initial DNS...');
const userConfig = await db.userConfigs.get();
await db.userConfigs.update({
...userConfig,
defaultDns: WG_INITIAL_ENV.DNS,
});
}
if (
WG_INITIAL_ENV.USERNAME &&
WG_INITIAL_ENV.PASSWORD &&
WG_INITIAL_ENV.HOST &&
WG_INITIAL_ENV.PORT
) {
DB_DEBUG('Creating initial user...');
await db.users.create(WG_INITIAL_ENV.USERNAME, WG_INITIAL_ENV.PASSWORD);
DB_DEBUG('Setting initial host and port...');
await db.userConfigs.updateHostPort(
WG_INITIAL_ENV.HOST,
WG_INITIAL_ENV.PORT
);
await db.general.setSetupStep(0);
}
}

View File

@@ -19,6 +19,19 @@ export const WG_ENV = {
PORT: assertEnv('PORT'),
};
export const WG_INITIAL_ENV = {
ENABLED: process.env.INIT_ENABLED === 'true',
USERNAME: process.env.INIT_USERNAME,
PASSWORD: process.env.INIT_PASSWORD,
DNS: process.env.INIT_DNS?.split(',').map((x) => x.trim()),
IPV4_CIDR: process.env.INIT_IPV4_CIDR,
IPV6_CIDR: process.env.INIT_IPV6_CIDR,
HOST: process.env.INIT_HOST,
PORT: process.env.INIT_PORT
? Number.parseInt(process.env.INIT_PORT, 10)
: undefined,
};
function assertEnv<T extends string>(env: T) {
const val = process.env[env];