企业内部服务需要安全访问时,自托管 VPN 能避免把流量交给第三方。Firezone 作为开源方案,底层采用 WireGuard,性能与安全性都比较理想。但官方后台不一定完全贴合团队工作流,此时用 Vue 3 重新构建一个管理界面,可以把用户审批、设备绑定、访问策略整合进内部系统。下面会从部署准备到前端工程化逐步说明。

部署 Firezone 并准备 API 凭据
Firezone 官方推荐使用 Docker Compose 部署,核心服务包括 Phoenix 应用、PostgreSQL 数据库以及 WireGuard 网关。一个最小化的 compose 配置可以这样写:
version: "3.8"
services:
firezone:
image: firezone/firezone:latest
ports:
- "51820:51820/udp"
- "13000:13000/tcp"
env_file:
- .env
depends_on:
- postgres
postgres:
image: postgres:15
environment:
POSTGRES_DB: firezone
POSTGRES_USER: firezone
POSTGRES_PASSWORD: change_me
这里的端口 51820 负责 WireGuard 数据面,13000 是 Web 控制台与 API 入口。启动后需要进入容器执行初始化命令,创建管理员账号。之后登录 Web 界面,在安全设置中生成 API Token。建议为前端集成单独创建一个受限角色,只授予用户、设备和规则的读取与创建权限,不要使用管理员 Token,这样即使前端被攻破,影响范围也更小。
如果 Vue 3 应用与服务端不在同一域名下,会触发跨域限制。Firezone 支持配置 CORS 来源,但更稳妥的做法是让前端请求先打到自己的 BFF 层,由 BFF 附带 Token 转发到 Firezone。这种方式能隐藏 Token,也能统一处理权限校验,后文会继续讨论。
用 TypeScript 封装 Firezone API 客户端
前端工程化的第一步是把请求逻辑集中起来,不要在组件里散落 fetch 调用。可以基于 Axios 创建一个实例,统一设置 baseURL、超时和认证头。下面是一个 request 封装:
import axios, { AxiosRequestConfig, AxiosError } from 'axios';
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
timeout: 10000,
});
http.interceptors.request.use((config) => {
const token = sessionStorage.getItem('firezone_token');
if (token) {
config.headers = config.headers ?? {};
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
http.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
window.dispatchEvent(new Event('firezone:unauthorized'));
}
return Promise.reject(error);
}
);
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
const response = await http.request<T>(config);
return response.data;
}
上面代码用到了 TypeScript 泛型,让每个接口能返回对应类型。注意 import.meta.env 是 Vite 暴露环境变量的方式,只有以 VITE_ 开头的变量会注入前端。Token 存储选择了 sessionStorage,会话关闭即清除,比长期保存在 localStorage 稍安全一些,但依然有 XSS 风险,生产环境建议配合 BFF 使用 HttpOnly Cookie。
接下来定义 Firezone 资源类型。Firezone 的 REST API 返回 JSON,用户、设备、规则对象大致如下:
export interface FirezoneUser {
id: string;
email: string;
role: 'admin' | 'unprivileged';
disabled_at: string | null;
}
export interface FirezoneDevice {
id: string;
user_id: string;
name: string;
public_key: string;
ipv4: string;
ipv6: string;
created_at: string;
}
export interface FirezoneRule {
id: string;
action: 'allow' | 'deny';
destination: string;
user_id: string | null;
port: string;
}
有了类型,再封装组合式函数,组件中就不需要关心请求细节。这个函数维护 loading、error 和 data 三个响应式状态:
import { ref } from 'vue';
import { request } from './client';
import type { FirezoneUser } from './types';
export function useUsers() {
const users = ref<FirezoneUser[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchUsers() {
loading.value = true;
error.value = null;
try {
users.value = await request<FirezoneUser[]>({ url: '/users' });
} catch (err: any) {
error.value = err?.message ?? '加载用户失败';
} finally {
loading.value = false;
}
}
return { users, loading, error, fetchUsers };
}
这种 composable 可以复用在用户列表、设备列表等页面。不要把 users.value 直接暴露给模板进行复杂计算,复杂过滤逻辑应该交给随后介绍的 Pinia store。
使用 Pinia 管理用户与设备状态
当多个组件都需要访问用户列表或当前设备状态时,只靠组件内状态会造成重复请求和状态不同步。Pinia 可以把数据缓存在全局 store 中,并对外暴露异步 actions。示例:
import { defineStore } from 'pinia';
import { request } from '../api/client';
import type { FirezoneUser, FirezoneDevice } from '../api/types';
interface FirezoneState {
users: FirezoneUser[];
devices: FirezoneDevice[];
loading: boolean;
}
export const useFirezoneStore = defineStore('firezone', {
state: (): FirezoneState => ({
users: [],
devices: [],
loading: false,
}),
getters: {
activeUsers(state) {
return state.users.filter((user) => !user.disabled_at);
},
onlineDevices(state) {
return state.devices.filter((device) => {
const lastSeen = Date.parse(device.created_at);
return Date.now() - lastSeen < 24 * 60 * 60 * 1000;
});
},
},
actions: {
async loadUsers() {
this.loading = true;
try {
this.users = await request<FirezoneUser[]>({ url: '/users' });
} finally {
this.loading = false;
}
},
async createUser(email: string) {
const user = await request<FirezoneUser>({
url: '/users',
method: 'POST',
data: { email },
});
this.users.push(user);
return user;
},
},
});
这里的 onlineDevices getter 只是一个示例,真实场景中需要根据 Firezone 返回的设备状态字段来判断,而不是使用 created_at 做近似。如果后端 API 没有提供在线状态,可以结合 WebSocket 或轮询更新。
在组件中使用 store 时,可以直接调用 await store.loadUsers() 并绑定 store.users。为了避免模板过度依赖复杂判断,建议在 setup 中用 computed 二次整理数据。表单单页中创建用户后,无需手动刷新列表,因为 store 已经把新对象追加到数组。
实现设备配置下载与客户端接入
管理员在 Vue 3 后台创建用户后,下一步通常是为其生成设备并下载 WireGuard 配置文件。Firezone 提供配置下载接口,前端可以调用并转化为文件下载。为了不把 Token 拼接进 URL,最好通过 Axios 以 blob 类型请求,再创建对象 URL:
import { http } from '../client';
export async function downloadDeviceConfig(deviceId: string) {
const response = await http.get(`/devices/${deviceId}/configuration`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(response.data);
const link = document.createElement('a');
link.href = url;
link.download = `firezone-${deviceId}.conf`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
}
下载得到的 .conf 文件可以直接导入 WireGuard 官方客户端或 Firezone 客户端。移动端用户也可以扫描二维码,如果要在 Vue 3 页面中展示二维码,可以调用后端返回的文本配置,用 qrcode 这类库生成。图片二维码生成属于纯前端操作,不会把配置上传到第三方服务,但要注意页面渲染安全,避免把完整私钥暴露在日志中。
设备配置过程中,前端表单需要校验设备名称、公钥格式等。公钥通常是 Base64 编码的 32 字节,校验时可以检查长度和字符集:
function isValidPublicKey(key: string): boolean {
return /^[A-Za-z0-9+/]{43}=$/.test(key.trim());
}
服务端仍应做最终校验,前端校验只是为了提升交互体验。对于 VPN 这类安全敏感功能,不要因为前端限制而省略后端验证。
生产环境安全与工程化细节
开发阶段常用 Vite 代理解决跨域。下面配置把 /api 请求转发到本机 Firezone 服务,并保留路径:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:13000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});
但在生产环境,更好的架构是通过 BFF 层转发。例如 Node.js 后端或云函数接收前端请求,校验登录态后再附加 Firezone API Token 调用。这样浏览器中根本不会出现 Firezone 的高权限 Token,减少了泄漏面。如果团队规模较小,只能直接从前端调用 Firezone,务必为前端 Token 设置最小权限,并配置 IP 白名单或短期过期策略。
还需要注意错误信息脱敏。Firezone 的 API 错误可能包含内部路径或堆栈信息,不要直接把原始错误展示给用户。可以在 Axios 拦截器里统一提取状态码和通用信息,例如 403 显示无权限,422 展示字段校验错误,其他情况输出通用失败提示。日志记录时也要过滤 Authorization 请求头。
整套前端工程可以拆分为 api、stores、composables 和 views 四层。API 层只关心请求与类型,stores 层处理缓存和业务动作,composables 抽象复用逻辑,views 负责界面交互。这种划分让接入 Firezone 的代码保持在可控范围,后续升级或替换 VPN 后端时,改动也不会蔓延到每个组件。