mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-02 17:15:43 +00:00
refactor(web): use generated proto network types (#2373)
* refactor(web): use generated proto network types * fix(core): preserve dumped config flags * test(web): cover config flag save paths * fix(ci): use system protoc before frontend codegen * fix(ci): serialize frontend-lib builds
This commit is contained in:
@@ -33,6 +33,19 @@ runs:
|
|||||||
sudo apt-get install -qqy build-essential mold musl-tools
|
sudo apt-get install -qqy build-essential mold musl-tools
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
- name: Setup protoc
|
||||||
|
uses: arduino/setup-protoc@v3
|
||||||
|
with:
|
||||||
|
version: '35.1'
|
||||||
|
# GitHub repo token to use to avoid rate limiter
|
||||||
|
repo-token: ${{ inputs.token }}
|
||||||
|
|
||||||
|
- name: Verify protoc version
|
||||||
|
run: |
|
||||||
|
version="$(protoc --version | tr -d '\r')"
|
||||||
|
test "$version" = "libprotoc 35.1"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Setup Frontend Environment
|
- name: Setup Frontend Environment
|
||||||
if: ${{ inputs.pnpm == 'true' }}
|
if: ${{ inputs.pnpm == 'true' }}
|
||||||
uses: ./.github/actions/prepare-pnpm
|
uses: ./.github/actions/prepare-pnpm
|
||||||
@@ -82,9 +95,3 @@ runs:
|
|||||||
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
|
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
|
||||||
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
|
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Setup protoc
|
|
||||||
uses: arduino/setup-protoc@v3
|
|
||||||
with:
|
|
||||||
# GitHub repo token to use to avoid rate limiter
|
|
||||||
repo-token: ${{ inputs.token }}
|
|
||||||
@@ -41,8 +41,8 @@ runs:
|
|||||||
pnpm -r install
|
pnpm -r install
|
||||||
if [ -n "${{ inputs.build-filter }}" ]; then
|
if [ -n "${{ inputs.build-filter }}" ]; then
|
||||||
echo "Building with filter: ${{ inputs.build-filter }}"
|
echo "Building with filter: ${{ inputs.build-filter }}"
|
||||||
pnpm -r --filter "${{ inputs.build-filter }}" build
|
pnpm -r --workspace-concurrency=1 --filter "${{ inputs.build-filter }}" build
|
||||||
else
|
else
|
||||||
echo "No build filter provided, building all packages"
|
echo "No build filter provided, building all packages"
|
||||||
pnpm -r build
|
pnpm -r --workspace-concurrency=1 build
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ easytier-panic.log
|
|||||||
|
|
||||||
# web
|
# web
|
||||||
node_modules
|
node_modules
|
||||||
|
easytier-web/frontend-lib/src/generated/
|
||||||
|
|
||||||
.vite
|
.vite
|
||||||
|
|
||||||
|
|||||||
Generated
+3
@@ -6897,9 +6897,12 @@ version = "0.16.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "590aa145fee8f7a26b5a6055365e7c5e89a5c1caae9869de76ec0ee73181a2f9"
|
checksum = "590aa145fee8f7a26b5a6055365e7c5e89a5c1caae9869de76ec0ee73181a2f9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"prost 0.14.3",
|
"prost 0.14.3",
|
||||||
"prost-reflect-derive",
|
"prost-reflect-derive",
|
||||||
"prost-types 0.14.3",
|
"prost-types 0.14.3",
|
||||||
|
"serde",
|
||||||
|
"serde-value",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
|
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "pnpm --dir ../easytier-web/frontend-lib build && vite",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "pnpm --dir ../easytier-web/frontend-lib build && vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"lint": "eslint . --ignore-pattern src-tauri",
|
"lint": "eslint . --ignore-pattern src-tauri",
|
||||||
|
|||||||
@@ -13,12 +13,18 @@
|
|||||||
"./*.css": "./dist/*.css"
|
"./*.css": "./dist/*.css"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"codegen:proto": "node scripts/codegen-proto.mjs",
|
||||||
"build": "vue-tsc -b && vite build",
|
"dev": "pnpm codegen:proto && vite",
|
||||||
|
"build": "pnpm codegen:proto && vue-tsc -b && vite build",
|
||||||
|
"test": "pnpm test:config-ui && pnpm test:network-config",
|
||||||
|
"test:config-ui": "pnpm codegen:proto && vitest run --config vitest.config.ts",
|
||||||
|
"test:network-config": "pnpm build && node scripts/test-network-config.mjs",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@primeuix/themes": "^1.2.3",
|
"@primeuix/themes": "^1.2.3",
|
||||||
|
"@protobuf-ts/runtime": "2.11.1",
|
||||||
|
"@protobuf-ts/runtime-rpc": "2.11.1",
|
||||||
"@vueuse/core": "^11.1.0",
|
"@vueuse/core": "^11.1.0",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
@@ -33,9 +39,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||||
|
"@protobuf-ts/plugin": "2.11.1",
|
||||||
|
"@protobuf-ts/protoc": "2.11.1",
|
||||||
"@types/node": "^22.8.6",
|
"@types/node": "^22.8.6",
|
||||||
"@vitejs/plugin-vue": "^5.1.4",
|
"@vitejs/plugin-vue": "^5.1.4",
|
||||||
|
"@vue/test-utils": "^2.4.11",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"happy-dom": "16.8.1",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"postcss-import": "^16.1.0",
|
"postcss-import": "^16.1.0",
|
||||||
"postcss-nested": "^7.0.2",
|
"postcss-nested": "^7.0.2",
|
||||||
@@ -43,10 +53,11 @@
|
|||||||
"typescript": "~5.6.3",
|
"typescript": "~5.6.3",
|
||||||
"vite": "^5.4.21",
|
"vite": "^5.4.21",
|
||||||
"vite-plugin-dts": "^4.3.0",
|
"vite-plugin-dts": "^4.3.0",
|
||||||
|
"vitest": "^2.1.9",
|
||||||
"vue-tsc": "^2.1.10"
|
"vue-tsc": "^2.1.10"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "^3.5.12",
|
"primevue": "^4.3.9",
|
||||||
"primevue": "^4.3.9"
|
"vue": "^3.5.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+2482
-10
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
|||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { delimiter, dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const protoRoot = resolve(root, '../../easytier/src/proto')
|
||||||
|
const generatedRoot = resolve(root, 'src/generated')
|
||||||
|
const outDir = resolve(generatedRoot, 'proto')
|
||||||
|
const nodeBinDir = resolve(root, 'node_modules/.bin')
|
||||||
|
|
||||||
|
const protocWrapper = require.resolve('@protobuf-ts/protoc/protoc.js')
|
||||||
|
const protobufTsPluginRoot = dirname(require.resolve('@protobuf-ts/plugin/package.json'))
|
||||||
|
|
||||||
|
const protoFiles = [
|
||||||
|
'common.proto',
|
||||||
|
'acl.proto',
|
||||||
|
'api_instance.proto',
|
||||||
|
'api_manage.proto',
|
||||||
|
'peer_rpc.proto',
|
||||||
|
'error.proto',
|
||||||
|
]
|
||||||
|
|
||||||
|
function installGeneratedFiles(fromDir, toDir) {
|
||||||
|
mkdirSync(toDir, { recursive: true })
|
||||||
|
|
||||||
|
for (const entry of readdirSync(fromDir)) {
|
||||||
|
const source = resolve(fromDir, entry)
|
||||||
|
const target = resolve(toDir, entry)
|
||||||
|
|
||||||
|
if (statSync(source).isDirectory()) {
|
||||||
|
installGeneratedFiles(source, target)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
renameSync(source, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findExecutableInPath(command, extensions = ['']) {
|
||||||
|
const envPath = process.env[pathEnvKey()]
|
||||||
|
if (typeof envPath !== 'string') return undefined
|
||||||
|
|
||||||
|
const nodeBinSuffix = ['node_modules/.bin', 'node_modules\\.bin']
|
||||||
|
for (const entry of envPath.split(delimiter)) {
|
||||||
|
if (!entry || nodeBinSuffix.some((suffix) => entry.endsWith(suffix))) continue
|
||||||
|
|
||||||
|
for (const extension of extensions) {
|
||||||
|
const candidate = resolve(entry, `${command}${extension}`)
|
||||||
|
if (existsSync(candidate)) return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathEnvKey() {
|
||||||
|
return Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'PATH'
|
||||||
|
}
|
||||||
|
|
||||||
|
function withNodeBinPath() {
|
||||||
|
const key = pathEnvKey()
|
||||||
|
const currentPath = process.env[key]
|
||||||
|
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
[key]: currentPath ? `${nodeBinDir}${delimiter}${currentPath}` : nodeBinDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProtocCommand() {
|
||||||
|
const extensions = process.platform === 'win32' ? ['.exe'] : ['']
|
||||||
|
const systemProtoc = findExecutableInPath('protoc', extensions)
|
||||||
|
|
||||||
|
if (systemProtoc) {
|
||||||
|
return {
|
||||||
|
command: systemProtoc,
|
||||||
|
argsPrefix: ['--proto_path', protobufTsPluginRoot],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: process.execPath,
|
||||||
|
argsPrefix: [protocWrapper],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(generatedRoot, { recursive: true })
|
||||||
|
const tmpDir = mkdtempSync(resolve(generatedRoot, '.proto-'))
|
||||||
|
const protocCommand = getProtocCommand()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(protocCommand.command, [
|
||||||
|
...protocCommand.argsPrefix,
|
||||||
|
'-I',
|
||||||
|
protoRoot,
|
||||||
|
`--ts_out=${tmpDir}`,
|
||||||
|
'--ts_opt=use_proto_field_name,server_none,client_none,ts_nocheck',
|
||||||
|
...protoFiles.map((file) => resolve(protoRoot, file)),
|
||||||
|
], {
|
||||||
|
cwd: root,
|
||||||
|
env: withNodeBinPath(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = result.status ?? 1
|
||||||
|
if (status === 0) {
|
||||||
|
installGeneratedFiles(tmpDir, outDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(status)
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const projectRoot = path.resolve(__dirname, '..')
|
||||||
|
const generatedApiManagePath = path.join(projectRoot, 'src/generated/proto/api_manage.ts')
|
||||||
|
const distPath = path.join(projectRoot, 'dist/easytier-frontend-lib.js')
|
||||||
|
|
||||||
|
const { NetworkTypes } = await import(pathToFileURL(distPath))
|
||||||
|
|
||||||
|
const {
|
||||||
|
AclAction,
|
||||||
|
AclChainType,
|
||||||
|
AclProtocol,
|
||||||
|
CompressionAlgoPb,
|
||||||
|
DEFAULT_NETWORK_CONFIG,
|
||||||
|
NetworkingMethod,
|
||||||
|
normalizeNetworkConfig,
|
||||||
|
toBackendNetworkConfig,
|
||||||
|
} = NetworkTypes
|
||||||
|
|
||||||
|
const BOOLEAN_CONFIG_FIELDS = [
|
||||||
|
'dhcp',
|
||||||
|
'enable_vpn_portal',
|
||||||
|
'advanced_settings',
|
||||||
|
'latency_first',
|
||||||
|
'use_smoltcp',
|
||||||
|
'disable_ipv6',
|
||||||
|
'enable_kcp_proxy',
|
||||||
|
'disable_kcp_input',
|
||||||
|
'disable_p2p',
|
||||||
|
'bind_device',
|
||||||
|
'no_tun',
|
||||||
|
'enable_exit_node',
|
||||||
|
'relay_all_peer_rpc',
|
||||||
|
'multi_thread',
|
||||||
|
'enable_relay_network_whitelist',
|
||||||
|
'enable_manual_routes',
|
||||||
|
'proxy_forward_by_system',
|
||||||
|
'disable_encryption',
|
||||||
|
'enable_socks5',
|
||||||
|
'disable_udp_hole_punching',
|
||||||
|
'enable_magic_dns',
|
||||||
|
'enable_private_mode',
|
||||||
|
'enable_quic_proxy',
|
||||||
|
'disable_quic_input',
|
||||||
|
'disable_sym_hole_punching',
|
||||||
|
'p2p_only',
|
||||||
|
'lazy_p2p',
|
||||||
|
'need_p2p',
|
||||||
|
'disable_upnp',
|
||||||
|
'ipv6_public_addr_provider',
|
||||||
|
'ipv6_public_addr_auto',
|
||||||
|
'disable_relay_data',
|
||||||
|
'enable_udp_broadcast_relay',
|
||||||
|
'disable_tcp_hole_punching',
|
||||||
|
]
|
||||||
|
|
||||||
|
function readGeneratedNetworkConfigFields() {
|
||||||
|
const source = ts.createSourceFile(
|
||||||
|
generatedApiManagePath,
|
||||||
|
fs.readFileSync(generatedApiManagePath, 'utf8'),
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const statement of source.statements) {
|
||||||
|
if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'NetworkConfig') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return statement.members
|
||||||
|
.filter(ts.isPropertySignature)
|
||||||
|
.map((member) => member.name.getText(source).replace(/^['"]|['"]$/g, ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`NetworkConfig interface not found in ${generatedApiManagePath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectNoCamelCaseKeys(value, pathSegments = []) {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item, index) => expectNoCamelCaseKeys(item, [...pathSegments, String(index)]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
assert.equal(
|
||||||
|
/[A-Z]/.test(key),
|
||||||
|
false,
|
||||||
|
`JSON key should use proto field name: ${[...pathSegments, key].join('.')}`,
|
||||||
|
)
|
||||||
|
expectNoCamelCaseKeys(child, [...pathSegments, key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function allFieldFixture() {
|
||||||
|
return {
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
instance_id: '11111111-2222-3333-4444-555555555555',
|
||||||
|
dhcp: false,
|
||||||
|
virtual_ipv4: '10.9.8.7',
|
||||||
|
network_length: 25,
|
||||||
|
hostname: 'frontend-e2e',
|
||||||
|
network_name: 'full-field-network',
|
||||||
|
network_secret: 'full-field-secret',
|
||||||
|
networking_method: NetworkingMethod.Manual,
|
||||||
|
public_server_url: 'tcp://public.example:11010',
|
||||||
|
peer_urls: [' tcp://peer-a:11010 ', '', 'udp://peer-b:11010'],
|
||||||
|
proxy_cidrs: ['10.10.0.0/16', '192.168.2.0/24->10.99.0.0/24'],
|
||||||
|
enable_vpn_portal: true,
|
||||||
|
vpn_portal_listen_port: 23000,
|
||||||
|
vpn_portal_client_network_addr: '10.88.0.0',
|
||||||
|
vpn_portal_client_network_len: 24,
|
||||||
|
advanced_settings: true,
|
||||||
|
listener_urls: ['tcp://0.0.0.0:12010', 'udp://0.0.0.0:12010'],
|
||||||
|
latency_first: true,
|
||||||
|
dev_name: 'et-full',
|
||||||
|
use_smoltcp: true,
|
||||||
|
disable_ipv6: true,
|
||||||
|
enable_kcp_proxy: true,
|
||||||
|
disable_kcp_input: true,
|
||||||
|
disable_p2p: true,
|
||||||
|
bind_device: false,
|
||||||
|
no_tun: true,
|
||||||
|
enable_exit_node: true,
|
||||||
|
relay_all_peer_rpc: true,
|
||||||
|
multi_thread: false,
|
||||||
|
enable_relay_network_whitelist: true,
|
||||||
|
relay_network_whitelist: ['10.0.0.0/8', 'fd00::/8'],
|
||||||
|
enable_manual_routes: true,
|
||||||
|
routes: ['10.20.0.0/16', 'fd00:20::/64'],
|
||||||
|
exit_nodes: ['10.9.8.1', 'fd00::1'],
|
||||||
|
proxy_forward_by_system: true,
|
||||||
|
disable_encryption: true,
|
||||||
|
enable_socks5: true,
|
||||||
|
socks5_port: 1081,
|
||||||
|
disable_udp_hole_punching: true,
|
||||||
|
mtu: 1280,
|
||||||
|
mapped_listeners: ['tcp://127.0.0.1:13010'],
|
||||||
|
enable_magic_dns: true,
|
||||||
|
enable_private_mode: true,
|
||||||
|
enable_quic_proxy: true,
|
||||||
|
disable_quic_input: true,
|
||||||
|
quic_listen_port: 14010,
|
||||||
|
port_forwards: [
|
||||||
|
{
|
||||||
|
proto: 'tcp',
|
||||||
|
bind_ip: '127.0.0.1',
|
||||||
|
bind_port: 8080,
|
||||||
|
dst_ip: '10.9.8.7',
|
||||||
|
dst_port: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proto: 'udp',
|
||||||
|
bind_ip: '0.0.0.0',
|
||||||
|
bind_port: 5353,
|
||||||
|
dst_ip: '10.9.8.8',
|
||||||
|
dst_port: 53,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
disable_sym_hole_punching: true,
|
||||||
|
p2p_only: true,
|
||||||
|
data_compress_algo: CompressionAlgoPb.Zstd,
|
||||||
|
encryption_algorithm: 'aes-gcm',
|
||||||
|
disable_tcp_hole_punching: true,
|
||||||
|
secure_mode: {
|
||||||
|
enabled: true,
|
||||||
|
local_private_key: 'private-key',
|
||||||
|
local_public_key: 'public-key',
|
||||||
|
},
|
||||||
|
acl: {
|
||||||
|
acl_v1: {
|
||||||
|
group: {
|
||||||
|
declares: [
|
||||||
|
{
|
||||||
|
group_name: 'ops',
|
||||||
|
group_secret: 'ops-secret',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
members: ['node-a', 'node-b'],
|
||||||
|
},
|
||||||
|
chains: [
|
||||||
|
{
|
||||||
|
name: 'forward-chain',
|
||||||
|
chain_type: AclChainType.Forward,
|
||||||
|
description: 'forward traffic',
|
||||||
|
enabled: true,
|
||||||
|
default_action: AclAction.Drop,
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
name: 'allow-web',
|
||||||
|
description: 'allow web traffic',
|
||||||
|
priority: 100,
|
||||||
|
enabled: true,
|
||||||
|
protocol: AclProtocol.TCP,
|
||||||
|
ports: ['80', '443'],
|
||||||
|
source_ips: ['10.0.0.0/8'],
|
||||||
|
destination_ips: ['10.9.8.7/32'],
|
||||||
|
source_ports: ['1024-65535'],
|
||||||
|
action: AclAction.Allow,
|
||||||
|
rate_limit: 1000,
|
||||||
|
burst_limit: 2000,
|
||||||
|
stateful: true,
|
||||||
|
source_groups: ['ops'],
|
||||||
|
destination_groups: ['web'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
credential_file: '/tmp/easytier-credential.toml',
|
||||||
|
lazy_p2p: true,
|
||||||
|
need_p2p: true,
|
||||||
|
instance_recv_bps_limit: '9007199254740993',
|
||||||
|
disable_upnp: true,
|
||||||
|
ipv6_public_addr_provider: true,
|
||||||
|
ipv6_public_addr_auto: true,
|
||||||
|
ipv6_public_addr_prefix: '2001:db8:1::/64',
|
||||||
|
disable_relay_data: true,
|
||||||
|
enable_udp_broadcast_relay: true,
|
||||||
|
socket_mark: 1234,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFixtureCoversGeneratedFields() {
|
||||||
|
const generatedFields = readGeneratedNetworkConfigFields()
|
||||||
|
const fixtureFields = new Set(Object.keys(allFieldFixture()))
|
||||||
|
const missing = generatedFields.filter((field) => !fixtureFields.has(field))
|
||||||
|
|
||||||
|
assert.deepEqual(missing, [], 'all generated NetworkConfig fields should be represented in the fixture')
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFullFieldRoundTrip() {
|
||||||
|
const input = allFieldFixture()
|
||||||
|
const normalized = normalizeNetworkConfig(input)
|
||||||
|
|
||||||
|
assert.equal(normalized.peer_urls.join(','), 'tcp://peer-a:11010,udp://peer-b:11010')
|
||||||
|
assert.equal(normalized.instance_recv_bps_limit, '9007199254740993')
|
||||||
|
assert.equal(normalized.data_compress_algo, CompressionAlgoPb.Zstd)
|
||||||
|
assert.equal(normalized.acl.acl_v1.chains[0].chain_type, AclChainType.Forward)
|
||||||
|
assert.equal(normalized.acl.acl_v1.chains[0].rules[0].protocol, AclProtocol.TCP)
|
||||||
|
|
||||||
|
const backend = toBackendNetworkConfig(normalized)
|
||||||
|
expectNoCamelCaseKeys(backend)
|
||||||
|
|
||||||
|
for (const field of readGeneratedNetworkConfigFields()) {
|
||||||
|
assert.ok(field in backend, `backend JSON should include fixture field ${field}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(backend.networking_method, 'Manual')
|
||||||
|
assert.equal(backend.public_server_url, '')
|
||||||
|
assert.deepEqual(backend.peer_urls, ['tcp://peer-a:11010', 'udp://peer-b:11010'])
|
||||||
|
assert.equal(backend.data_compress_algo, 'Zstd')
|
||||||
|
assert.equal(backend.instance_recv_bps_limit, '9007199254740993')
|
||||||
|
assert.equal(backend.secure_mode.enabled, true)
|
||||||
|
assert.equal(backend.secure_mode.local_private_key, 'private-key')
|
||||||
|
assert.equal(backend.acl.acl_v1.chains[0].chain_type, 'Forward')
|
||||||
|
assert.equal(backend.acl.acl_v1.chains[0].default_action, 'Drop')
|
||||||
|
assert.equal(backend.acl.acl_v1.chains[0].rules[0].protocol, 'TCP')
|
||||||
|
assert.equal(backend.acl.acl_v1.chains[0].rules[0].action, 'Allow')
|
||||||
|
assert.equal(backend.port_forwards[1].proto, 'udp')
|
||||||
|
assert.equal(backend.socket_mark, 1234)
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBooleanFieldValuesPreserved() {
|
||||||
|
const input = allFieldFixture()
|
||||||
|
const normalized = normalizeNetworkConfig(input)
|
||||||
|
const backend = toBackendNetworkConfig(normalized)
|
||||||
|
|
||||||
|
for (const field of BOOLEAN_CONFIG_FIELDS) {
|
||||||
|
assert.equal(
|
||||||
|
normalized[field],
|
||||||
|
input[field],
|
||||||
|
`normalized config should preserve boolean field ${field}`,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
backend[field],
|
||||||
|
input[field],
|
||||||
|
`backend JSON should preserve boolean field ${field}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEnumCompatibility() {
|
||||||
|
const normalized = normalizeNetworkConfig({
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
networking_method: 'Manual',
|
||||||
|
data_compress_algo: 'Zstd',
|
||||||
|
acl: {
|
||||||
|
acl_v1: {
|
||||||
|
group: { declares: [], members: [] },
|
||||||
|
chains: [
|
||||||
|
{
|
||||||
|
chain_type: 'Forward',
|
||||||
|
default_action: 'Drop',
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
protocol: 'TCP',
|
||||||
|
action: 'Allow',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(normalized.data_compress_algo, CompressionAlgoPb.Zstd)
|
||||||
|
assert.equal(normalized.acl.acl_v1.chains[0].chain_type, AclChainType.Forward)
|
||||||
|
assert.equal(normalized.acl.acl_v1.chains[0].default_action, AclAction.Drop)
|
||||||
|
assert.equal(normalized.acl.acl_v1.chains[0].rules[0].protocol, AclProtocol.TCP)
|
||||||
|
assert.equal(normalized.acl.acl_v1.chains[0].rules[0].action, AclAction.Allow)
|
||||||
|
|
||||||
|
const backend = toBackendNetworkConfig({
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
data_compress_algo: 'Zstd',
|
||||||
|
acl: {
|
||||||
|
acl_v1: {
|
||||||
|
group: { declares: [], members: [] },
|
||||||
|
chains: [
|
||||||
|
{
|
||||||
|
chain_type: 'Forward',
|
||||||
|
default_action: 'Drop',
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
protocol: 'TCP',
|
||||||
|
action: 'Allow',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(backend.data_compress_algo, 'Zstd')
|
||||||
|
assert.equal(backend.acl.acl_v1.chains[0].chain_type, 'Forward')
|
||||||
|
assert.equal(backend.acl.acl_v1.chains[0].rules[0].protocol, 'TCP')
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAclDefaultsAndExplicitZero() {
|
||||||
|
const partialAcl = normalizeNetworkConfig({
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
acl: {
|
||||||
|
acl_v1: {
|
||||||
|
group: { declares: [], members: [] },
|
||||||
|
chains: [{ rules: [{}] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const defaultedChain = partialAcl.acl.acl_v1.chains[0]
|
||||||
|
|
||||||
|
assert.equal(defaultedChain.chain_type, AclChainType.UnspecifiedChain)
|
||||||
|
assert.equal(defaultedChain.default_action, AclAction.Allow)
|
||||||
|
assert.equal(defaultedChain.rules[0].protocol, AclProtocol.Any)
|
||||||
|
assert.equal(defaultedChain.rules[0].action, AclAction.Allow)
|
||||||
|
|
||||||
|
const explicitZero = normalizeNetworkConfig({
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
acl: {
|
||||||
|
acl_v1: {
|
||||||
|
group: { declares: [], members: [] },
|
||||||
|
chains: [
|
||||||
|
{
|
||||||
|
chain_type: 0,
|
||||||
|
default_action: 0,
|
||||||
|
rules: [{ protocol: 0, action: 0 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const zeroChain = explicitZero.acl.acl_v1.chains[0]
|
||||||
|
|
||||||
|
assert.equal(zeroChain.chain_type, AclChainType.UnspecifiedChain)
|
||||||
|
assert.equal(zeroChain.default_action, AclAction.Noop)
|
||||||
|
assert.equal(zeroChain.rules[0].protocol, AclProtocol.Unspecified)
|
||||||
|
assert.equal(zeroChain.rules[0].action, AclAction.Noop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNetworkingMethodNormalization() {
|
||||||
|
const publicServer = normalizeNetworkConfig({
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
networking_method: 'PublicServer',
|
||||||
|
public_server_url: ' tcp://public.example:11010 ',
|
||||||
|
peer_urls: ['tcp://manual.example:11010'],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(publicServer.networking_method, NetworkingMethod.Manual)
|
||||||
|
assert.equal(publicServer.public_server_url, '')
|
||||||
|
assert.deepEqual(publicServer.peer_urls, ['tcp://public.example:11010'])
|
||||||
|
|
||||||
|
const standalone = normalizeNetworkConfig({
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
networking_method: 'Standalone',
|
||||||
|
peer_urls: ['tcp://manual.example:11010'],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(standalone.networking_method, NetworkingMethod.Manual)
|
||||||
|
assert.deepEqual(standalone.peer_urls, [])
|
||||||
|
|
||||||
|
const missing = normalizeNetworkConfig({
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
networking_method: undefined,
|
||||||
|
peer_urls: [' tcp://one ', '', 'udp://two '],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(missing.peer_urls, ['tcp://one', 'udp://two'])
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNumberBoundaries() {
|
||||||
|
const safeLimit = normalizeNetworkConfig({
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
instance_recv_bps_limit: '12345',
|
||||||
|
})
|
||||||
|
assert.equal(safeLimit.instance_recv_bps_limit, 12345)
|
||||||
|
|
||||||
|
const largeLimit = normalizeNetworkConfig({
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
instance_recv_bps_limit: '9007199254740993',
|
||||||
|
})
|
||||||
|
assert.equal(largeLimit.instance_recv_bps_limit, '9007199254740993')
|
||||||
|
assert.equal(toBackendNetworkConfig(largeLimit).instance_recv_bps_limit, '9007199254740993')
|
||||||
|
|
||||||
|
const invalidNumbers = normalizeNetworkConfig({
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
mtu: Number.NaN,
|
||||||
|
instance_recv_bps_limit: Number.POSITIVE_INFINITY,
|
||||||
|
})
|
||||||
|
assert.equal(invalidNumbers.mtu, null)
|
||||||
|
assert.equal(invalidNumbers.instance_recv_bps_limit, null)
|
||||||
|
|
||||||
|
const emptyLimit = normalizeNetworkConfig({
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
instance_recv_bps_limit: '',
|
||||||
|
})
|
||||||
|
assert.equal(emptyLimit.instance_recv_bps_limit, null)
|
||||||
|
|
||||||
|
const zeroLimit = normalizeNetworkConfig({
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
instance_recv_bps_limit: '0',
|
||||||
|
})
|
||||||
|
assert.equal(zeroLimit.instance_recv_bps_limit, null)
|
||||||
|
assert.equal(toBackendNetworkConfig({
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
instance_recv_bps_limit: 0,
|
||||||
|
}).instance_recv_bps_limit, undefined)
|
||||||
|
|
||||||
|
const oversizedLimit = normalizeNetworkConfig({
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
instance_recv_bps_limit: '18446744073709551616',
|
||||||
|
})
|
||||||
|
assert.equal(oversizedLimit.instance_recv_bps_limit, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tests = [
|
||||||
|
assertFixtureCoversGeneratedFields,
|
||||||
|
assertFullFieldRoundTrip,
|
||||||
|
assertBooleanFieldValuesPreserved,
|
||||||
|
assertEnumCompatibility,
|
||||||
|
assertAclDefaultsAndExplicitZero,
|
||||||
|
assertNetworkingMethodNormalization,
|
||||||
|
assertNumberBoundaries,
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
test()
|
||||||
|
console.log(`ok ${test.name}`)
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
normalizeNetworkConfig,
|
normalizeNetworkConfig,
|
||||||
removeRow
|
removeRow
|
||||||
} from '../types/network'
|
} from '../types/network'
|
||||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import AclManager from './acl/AclManager.vue'
|
import AclManager from './acl/AclManager.vue'
|
||||||
import UrlListInput from './UrlListInput.vue'
|
import UrlListInput from './UrlListInput.vue'
|
||||||
@@ -134,6 +134,7 @@ function savePortForward() {
|
|||||||
const portForwardContainer = ref<HTMLElement | null>(null);
|
const portForwardContainer = ref<HTMLElement | null>(null);
|
||||||
const isCompact = ref(false);
|
const isCompact = ref(false);
|
||||||
|
|
||||||
|
const UINT64_MAX = (1n << 64n) - 1n
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (portForwardContainer.value) {
|
if (portForwardContainer.value) {
|
||||||
@@ -161,6 +162,39 @@ function syncNormalizedNetwork(network: NetworkConfig | undefined): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: false })
|
watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: false })
|
||||||
|
|
||||||
|
function parseInstanceRecvBpsLimitInput(value: string): number | string | null | undefined {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(trimmed)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = BigInt(trimmed)
|
||||||
|
if (limit === 0n) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (limit > UINT64_MAX) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return limit <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(limit) : limit.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const instanceRecvBpsLimitInput = computed<string>({
|
||||||
|
get: () => {
|
||||||
|
const limit = curNetwork.value.instance_recv_bps_limit
|
||||||
|
return limit == null ? '' : String(limit)
|
||||||
|
},
|
||||||
|
set: (value) => {
|
||||||
|
const limit = parseInstanceRecvBpsLimitInput(value)
|
||||||
|
if (limit !== undefined) {
|
||||||
|
curNetwork.value.instance_recv_bps_limit = limit
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -317,9 +351,9 @@ watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: fa
|
|||||||
<span class="pi pi-question-circle ml-2 self-center"
|
<span class="pi pi-question-circle ml-2 self-center"
|
||||||
v-tooltip="t('instance_recv_bps_limit_help')"></span>
|
v-tooltip="t('instance_recv_bps_limit_help')"></span>
|
||||||
</div>
|
</div>
|
||||||
<InputNumber id="instance_recv_bps_limit" v-model="curNetwork.instance_recv_bps_limit"
|
<InputText id="instance_recv_bps_limit" v-model="instanceRecvBpsLimitInput"
|
||||||
aria-describedby="instance_recv_bps_limit-help" :format="false"
|
aria-describedby="instance_recv_bps_limit-help" inputmode="numeric" pattern="[0-9]*"
|
||||||
:placeholder="t('instance_recv_bps_limit_placeholder')" :min="1" fluid />
|
:placeholder="t('instance_recv_bps_limit_placeholder')" fluid />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,166 +1,49 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import {
|
||||||
|
NetworkConfig as NetworkConfigPb,
|
||||||
|
NetworkingMethod,
|
||||||
|
type NetworkConfig as ProtoNetworkConfig,
|
||||||
|
type PortForwardConfig,
|
||||||
|
} from '../generated/proto/api_manage'
|
||||||
|
import {
|
||||||
|
Action as AclAction,
|
||||||
|
ChainType as AclChainType,
|
||||||
|
Protocol as AclProtocol,
|
||||||
|
type Acl,
|
||||||
|
type AclV1,
|
||||||
|
type Chain as AclChain,
|
||||||
|
type GroupIdentity,
|
||||||
|
type GroupInfo,
|
||||||
|
type Rule as AclRule,
|
||||||
|
} from '../generated/proto/acl'
|
||||||
|
import { CompressionAlgoPb, NatType, type SecureModeConfig } from '../generated/proto/common'
|
||||||
|
import { prepareNetworkConfigForProtoJson } from './networkCompat'
|
||||||
|
|
||||||
export enum NetworkingMethod {
|
export { AclAction, AclChainType, AclProtocol, CompressionAlgoPb, NatType, NetworkingMethod }
|
||||||
PublicServer = 0,
|
export type { Acl, AclChain, AclRule, AclV1, GroupIdentity, GroupInfo, PortForwardConfig, SecureModeConfig }
|
||||||
Manual = 1,
|
|
||||||
Standalone = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SecureModeConfig {
|
export type NetworkConfig = Omit<
|
||||||
enabled: boolean
|
ProtoNetworkConfig,
|
||||||
// Keep protocol compatibility with backend/import-export flows even though the GUI
|
'instance_id' | 'instance_recv_bps_limit' | 'mtu' | 'networking_method'
|
||||||
// does not render secure-mode or credential inputs.
|
> & {
|
||||||
local_private_key?: string
|
|
||||||
local_public_key?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum AclProtocol {
|
|
||||||
Unspecified = 0,
|
|
||||||
TCP = 1,
|
|
||||||
UDP = 2,
|
|
||||||
ICMP = 3,
|
|
||||||
ICMPv6 = 4,
|
|
||||||
Any = 5,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum AclAction {
|
|
||||||
Noop = 0,
|
|
||||||
Allow = 1,
|
|
||||||
Drop = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum AclChainType {
|
|
||||||
UnspecifiedChain = 0,
|
|
||||||
Inbound = 1,
|
|
||||||
Outbound = 2,
|
|
||||||
Forward = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AclRule {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
priority: number
|
|
||||||
enabled: boolean
|
|
||||||
protocol: AclProtocol
|
|
||||||
ports: string[]
|
|
||||||
source_ips: string[]
|
|
||||||
destination_ips: string[]
|
|
||||||
source_ports: string[]
|
|
||||||
action: AclAction
|
|
||||||
rate_limit: number
|
|
||||||
burst_limit: number
|
|
||||||
stateful: boolean
|
|
||||||
source_groups: string[]
|
|
||||||
destination_groups: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AclChain {
|
|
||||||
name: string
|
|
||||||
chain_type: AclChainType
|
|
||||||
description: string
|
|
||||||
enabled: boolean
|
|
||||||
rules: AclRule[]
|
|
||||||
default_action: AclAction
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroupIdentity {
|
|
||||||
group_name: string
|
|
||||||
group_secret: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroupInfo {
|
|
||||||
declares: GroupIdentity[]
|
|
||||||
members: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AclV1 {
|
|
||||||
chains: AclChain[]
|
|
||||||
group?: GroupInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Acl {
|
|
||||||
acl_v1?: AclV1
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NetworkConfig {
|
|
||||||
instance_id: string
|
instance_id: string
|
||||||
|
|
||||||
dhcp: boolean
|
|
||||||
virtual_ipv4: string
|
|
||||||
network_length: number
|
|
||||||
hostname?: string
|
|
||||||
network_name: string
|
|
||||||
network_secret?: string
|
|
||||||
credential_file?: string
|
|
||||||
secure_mode?: SecureModeConfig
|
|
||||||
|
|
||||||
networking_method: NetworkingMethod
|
|
||||||
|
|
||||||
public_server_url: string
|
|
||||||
peer_urls: string[]
|
|
||||||
|
|
||||||
proxy_cidrs: string[]
|
|
||||||
|
|
||||||
enable_vpn_portal: boolean
|
|
||||||
vpn_portal_listen_port: number
|
|
||||||
vpn_portal_client_network_addr: string
|
|
||||||
vpn_portal_client_network_len: number
|
|
||||||
|
|
||||||
advanced_settings: boolean
|
|
||||||
|
|
||||||
listener_urls: string[]
|
|
||||||
latency_first: boolean
|
|
||||||
|
|
||||||
dev_name: string
|
|
||||||
|
|
||||||
use_smoltcp?: boolean
|
|
||||||
disable_ipv6?: boolean
|
|
||||||
ipv6_public_addr_auto?: boolean
|
|
||||||
enable_kcp_proxy?: boolean
|
|
||||||
disable_kcp_input?: boolean
|
|
||||||
enable_quic_proxy?: boolean
|
|
||||||
disable_quic_input?: boolean
|
|
||||||
disable_p2p?: boolean
|
|
||||||
p2p_only?: boolean
|
|
||||||
lazy_p2p?: boolean
|
|
||||||
bind_device?: boolean
|
|
||||||
no_tun?: boolean
|
|
||||||
enable_exit_node?: boolean
|
|
||||||
relay_all_peer_rpc?: boolean
|
|
||||||
need_p2p?: boolean
|
|
||||||
multi_thread?: boolean
|
|
||||||
proxy_forward_by_system?: boolean
|
|
||||||
disable_encryption?: boolean
|
|
||||||
disable_tcp_hole_punching?: boolean
|
|
||||||
disable_udp_hole_punching?: boolean
|
|
||||||
disable_upnp?: boolean
|
|
||||||
enable_udp_broadcast_relay?: boolean
|
|
||||||
disable_sym_hole_punching?: boolean
|
|
||||||
|
|
||||||
enable_relay_network_whitelist?: boolean
|
|
||||||
relay_network_whitelist: string[]
|
|
||||||
|
|
||||||
enable_manual_routes: boolean
|
|
||||||
routes: string[]
|
|
||||||
|
|
||||||
exit_nodes: string[]
|
|
||||||
|
|
||||||
enable_socks5?: boolean
|
|
||||||
socks5_port: number
|
|
||||||
|
|
||||||
mtu: number | null
|
mtu: number | null
|
||||||
instance_recv_bps_limit: number | null
|
instance_recv_bps_limit: number | string | null
|
||||||
mapped_listeners: string[]
|
networking_method: NetworkingMethod | string
|
||||||
|
}
|
||||||
|
|
||||||
enable_magic_dns?: boolean
|
const UINT64_MAX = (1n << 64n) - 1n
|
||||||
enable_private_mode?: boolean
|
|
||||||
|
|
||||||
port_forwards: PortForwardConfig[]
|
interface NetworkingConfigFields {
|
||||||
acl?: Acl
|
peer_urls: string[]
|
||||||
|
public_server_url?: string
|
||||||
|
networking_method?: NetworkingMethod | string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
|
export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
|
||||||
return {
|
return {
|
||||||
|
...NetworkConfigPb.create(),
|
||||||
|
|
||||||
instance_id: uuidv4(),
|
instance_id: uuidv4(),
|
||||||
|
|
||||||
dhcp: true,
|
dhcp: true,
|
||||||
@@ -243,33 +126,82 @@ function cleanPeerUrls(urls: string[] | undefined): string[] {
|
|||||||
return (urls ?? []).map((url) => url.trim()).filter((url) => url.length > 0)
|
return (urls ?? []).map((url) => url.trim()).filter((url) => url.length > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
|
function normalizeUint64ForInput(v: bigint | number | string | null | undefined): number | string | null {
|
||||||
const normalized: NetworkConfig = {
|
if (v == null) return null
|
||||||
...config,
|
|
||||||
peer_urls: cleanPeerUrls(config.peer_urls),
|
try {
|
||||||
|
const n = typeof v === 'bigint' ? v : BigInt(v)
|
||||||
|
if (n === 0n || n > UINT64_MAX) return null
|
||||||
|
return n <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(n) : n.toString()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const publicServerUrl = normalized.public_server_url?.trim() ?? ''
|
function normalizeNumberForInput(v: number | string | null | undefined): number | null {
|
||||||
|
if (v == null) return null
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
switch (normalized.networking_method) {
|
function toBackendUint64(v: number | bigint | string | null | undefined): bigint | undefined {
|
||||||
|
if (v == null || v === '') return undefined
|
||||||
|
try {
|
||||||
|
const n = typeof v === 'bigint' ? v : BigInt(v)
|
||||||
|
return n > 0n && n <= UINT64_MAX ? n : undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNetworkingMethod(config: NetworkingConfigFields): void {
|
||||||
|
config.peer_urls = cleanPeerUrls(config.peer_urls)
|
||||||
|
|
||||||
|
const publicServerUrl = config.public_server_url?.trim() ?? ''
|
||||||
|
const networkingMethod = config.networking_method ?? NetworkingMethod.Manual
|
||||||
|
|
||||||
|
switch (networkingMethod) {
|
||||||
case NetworkingMethod.PublicServer:
|
case NetworkingMethod.PublicServer:
|
||||||
normalized.peer_urls = publicServerUrl ? [publicServerUrl] : []
|
config.peer_urls = publicServerUrl ? [publicServerUrl] : []
|
||||||
break
|
break
|
||||||
case NetworkingMethod.Manual:
|
case NetworkingMethod.Manual:
|
||||||
break
|
break
|
||||||
case NetworkingMethod.Standalone:
|
case NetworkingMethod.Standalone:
|
||||||
default:
|
default:
|
||||||
normalized.peer_urls = []
|
config.peer_urls = []
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized.networking_method = NetworkingMethod.Manual
|
config.networking_method = NetworkingMethod.Manual
|
||||||
normalized.public_server_url = ''
|
config.public_server_url = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
|
||||||
|
const normalized = NetworkConfigPb.fromJson(prepareNetworkConfigForProtoJson(config) as any, {
|
||||||
|
ignoreUnknownFields: true,
|
||||||
|
}) as unknown as NetworkConfig
|
||||||
|
|
||||||
|
applyNetworkingMethod(normalized)
|
||||||
|
normalized.mtu = normalizeNumberForInput(normalized.mtu)
|
||||||
|
normalized.instance_recv_bps_limit = normalizeUint64ForInput(
|
||||||
|
normalized.instance_recv_bps_limit as any,
|
||||||
|
)
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig {
|
export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig {
|
||||||
return normalizeNetworkConfig(config)
|
const backend = NetworkConfigPb.fromJson(prepareNetworkConfigForProtoJson(config) as any, {
|
||||||
|
ignoreUnknownFields: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
applyNetworkingMethod(backend)
|
||||||
|
backend.mtu = normalizeNumberForInput(config.mtu) ?? undefined
|
||||||
|
backend.instance_recv_bps_limit = toBackendUint64(config.instance_recv_bps_limit)
|
||||||
|
|
||||||
|
return NetworkConfigPb.toJson(backend, {
|
||||||
|
useProtoFieldName: true,
|
||||||
|
}) as unknown as NetworkConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkInstance {
|
export interface NetworkInstance {
|
||||||
@@ -397,14 +329,6 @@ export interface PeerConnStats {
|
|||||||
latency_us: number
|
latency_us: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PortForwardConfig {
|
|
||||||
bind_ip: string,
|
|
||||||
bind_port: number,
|
|
||||||
dst_ip: string,
|
|
||||||
dst_port: number,
|
|
||||||
proto: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加新行
|
// 添加新行
|
||||||
export const addRow = (rows: PortForwardConfig[]) => {
|
export const addRow = (rows: PortForwardConfig[]) => {
|
||||||
rows.push({
|
rows.push({
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
Action as AclAction,
|
||||||
|
ChainType as AclChainType,
|
||||||
|
Protocol as AclProtocol,
|
||||||
|
} from '../generated/proto/acl'
|
||||||
|
import type { NetworkConfig } from './network'
|
||||||
|
|
||||||
|
const UINT64_MAX = (1n << 64n) - 1n
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>
|
||||||
|
|
||||||
|
export function prepareNetworkConfigForProtoJson(config: NetworkConfig): NetworkConfig {
|
||||||
|
const prepared = dropUnsupportedJsonValues(applyLegacyAclDefaults(config)) as NetworkConfig
|
||||||
|
normalizeLegacyOptionalUint64(prepared as JsonRecord, 'instance_recv_bps_limit')
|
||||||
|
return prepared
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLegacyAclDefaults(config: NetworkConfig): NetworkConfig {
|
||||||
|
const acl = config.acl
|
||||||
|
const aclV1 = acl?.acl_v1
|
||||||
|
if (!Array.isArray(aclV1?.chains)) return config
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
acl: {
|
||||||
|
...acl,
|
||||||
|
acl_v1: {
|
||||||
|
...aclV1,
|
||||||
|
chains: aclV1.chains.map((chain) => ({
|
||||||
|
...chain,
|
||||||
|
chain_type: chain.chain_type ?? AclChainType.UnspecifiedChain,
|
||||||
|
default_action: chain.default_action ?? AclAction.Allow,
|
||||||
|
rules: (chain.rules ?? []).map((rule) => ({
|
||||||
|
...rule,
|
||||||
|
protocol: rule.protocol ?? AclProtocol.Any,
|
||||||
|
action: rule.action ?? AclAction.Allow,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropUnsupportedJsonValues(value: unknown): unknown {
|
||||||
|
if (value === undefined) return undefined
|
||||||
|
if (typeof value === 'number' && !Number.isFinite(value)) return undefined
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(dropUnsupportedJsonValues).filter((v) => v !== undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJsonRecord(value)) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value)
|
||||||
|
.map(([k, v]) => [k, dropUnsupportedJsonValues(v)])
|
||||||
|
.filter(([, v]) => v !== undefined),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonRecord(value: unknown): value is JsonRecord {
|
||||||
|
return typeof value === 'object' && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLegacyOptionalUint64(obj: JsonRecord, key: string): void {
|
||||||
|
const value = obj[key]
|
||||||
|
if (typeof value !== 'string') return
|
||||||
|
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!isPositiveUint64String(trimmed)) {
|
||||||
|
delete obj[key]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
obj[key] = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPositiveUint64String(value: string): boolean {
|
||||||
|
if (!/^\d+$/.test(value)) return false
|
||||||
|
|
||||||
|
const n = BigInt(value)
|
||||||
|
return n > 0n && n <= UINT64_MAX
|
||||||
|
}
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { defineComponent, h, nextTick, reactive } from 'vue'
|
||||||
|
import Config from '../src/components/Config.vue'
|
||||||
|
import {
|
||||||
|
DEFAULT_NETWORK_CONFIG,
|
||||||
|
toBackendNetworkConfig,
|
||||||
|
type NetworkConfig,
|
||||||
|
} from '../src/types/network'
|
||||||
|
|
||||||
|
const CONFIG_FLAG_FIELDS = [
|
||||||
|
'latency_first',
|
||||||
|
'use_smoltcp',
|
||||||
|
'disable_ipv6',
|
||||||
|
'ipv6_public_addr_auto',
|
||||||
|
'enable_kcp_proxy',
|
||||||
|
'disable_kcp_input',
|
||||||
|
'enable_quic_proxy',
|
||||||
|
'disable_quic_input',
|
||||||
|
'disable_p2p',
|
||||||
|
'p2p_only',
|
||||||
|
'lazy_p2p',
|
||||||
|
'bind_device',
|
||||||
|
'no_tun',
|
||||||
|
'enable_exit_node',
|
||||||
|
'relay_all_peer_rpc',
|
||||||
|
'need_p2p',
|
||||||
|
'multi_thread',
|
||||||
|
'proxy_forward_by_system',
|
||||||
|
'disable_encryption',
|
||||||
|
'disable_tcp_hole_punching',
|
||||||
|
'disable_udp_hole_punching',
|
||||||
|
'enable_udp_broadcast_relay',
|
||||||
|
'disable_upnp',
|
||||||
|
'disable_sym_hole_punching',
|
||||||
|
'enable_magic_dns',
|
||||||
|
'enable_private_mode',
|
||||||
|
] as const satisfies readonly (keyof NetworkConfig)[]
|
||||||
|
|
||||||
|
const CONFIG_CHECKBOX_FIELDS = [
|
||||||
|
['dhcp', '#virtual_ip_auto'],
|
||||||
|
...CONFIG_FLAG_FIELDS.map((field) => [field, `#${field}`] as const),
|
||||||
|
] as const satisfies readonly (readonly [keyof NetworkConfig, string])[]
|
||||||
|
|
||||||
|
const CONFIG_TOGGLE_FIELDS = [
|
||||||
|
'enable_vpn_portal',
|
||||||
|
'enable_relay_network_whitelist',
|
||||||
|
'enable_manual_routes',
|
||||||
|
'enable_socks5',
|
||||||
|
] as const satisfies readonly (keyof NetworkConfig)[]
|
||||||
|
|
||||||
|
const CONFIG_UI_BOOLEAN_FIELDS = [
|
||||||
|
...CONFIG_CHECKBOX_FIELDS.map(([field]) => field),
|
||||||
|
...CONFIG_TOGGLE_FIELDS,
|
||||||
|
] as const satisfies readonly (keyof NetworkConfig)[]
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string, values?: unknown[]) => values ? `${key}:${values.join(',')}` : key,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const PassThrough = defineComponent({
|
||||||
|
name: 'PassThrough',
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h('div', slots.default?.())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const PanelStub = defineComponent({
|
||||||
|
name: 'Panel',
|
||||||
|
props: {
|
||||||
|
header: String,
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () => h('section', { 'data-stub': 'panel', 'data-header': props.header }, slots.default?.())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DividerStub = defineComponent({
|
||||||
|
name: 'Divider',
|
||||||
|
setup() {
|
||||||
|
return () => h('hr', { 'data-stub': 'divider' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function splitList(value: string): string[] {
|
||||||
|
return value.split(',').map((item) => item.trim()).filter((item) => item.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const InputTextStub = defineComponent({
|
||||||
|
name: 'InputText',
|
||||||
|
props: {
|
||||||
|
modelValue: [String, Number],
|
||||||
|
id: String,
|
||||||
|
disabled: Boolean,
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { attrs, emit }) {
|
||||||
|
return () => h('input', {
|
||||||
|
...attrs,
|
||||||
|
id: props.id,
|
||||||
|
disabled: props.disabled,
|
||||||
|
value: props.modelValue ?? '',
|
||||||
|
'data-stub': 'input-text',
|
||||||
|
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const PasswordStub = defineComponent({
|
||||||
|
name: 'Password',
|
||||||
|
props: {
|
||||||
|
modelValue: [String, Number],
|
||||||
|
id: String,
|
||||||
|
disabled: Boolean,
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { attrs, emit }) {
|
||||||
|
return () => h('input', {
|
||||||
|
...attrs,
|
||||||
|
id: props.id,
|
||||||
|
disabled: props.disabled,
|
||||||
|
type: 'password',
|
||||||
|
value: props.modelValue ?? '',
|
||||||
|
'data-stub': 'password',
|
||||||
|
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const InputNumberStub = defineComponent({
|
||||||
|
name: 'InputNumber',
|
||||||
|
props: {
|
||||||
|
modelValue: Number,
|
||||||
|
id: String,
|
||||||
|
inputId: String,
|
||||||
|
disabled: Boolean,
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { attrs, emit }) {
|
||||||
|
return () => h('input', {
|
||||||
|
...attrs,
|
||||||
|
id: props.id ?? props.inputId,
|
||||||
|
disabled: props.disabled,
|
||||||
|
type: 'number',
|
||||||
|
value: props.modelValue ?? '',
|
||||||
|
'data-stub': 'input-number',
|
||||||
|
onInput: (event: Event) => {
|
||||||
|
const value = (event.target as HTMLInputElement).value
|
||||||
|
emit('update:modelValue', value === '' ? null : Number(value))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const CheckboxStub = defineComponent({
|
||||||
|
name: 'Checkbox',
|
||||||
|
props: {
|
||||||
|
modelValue: Boolean,
|
||||||
|
inputId: String,
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { attrs, emit }) {
|
||||||
|
return () => h('input', {
|
||||||
|
...attrs,
|
||||||
|
id: props.inputId,
|
||||||
|
checked: props.modelValue,
|
||||||
|
type: 'checkbox',
|
||||||
|
'data-stub': 'checkbox',
|
||||||
|
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).checked),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ToggleButtonStub = defineComponent({
|
||||||
|
name: 'ToggleButton',
|
||||||
|
props: {
|
||||||
|
modelValue: Boolean,
|
||||||
|
onIcon: String,
|
||||||
|
offIcon: String,
|
||||||
|
onLabel: String,
|
||||||
|
offLabel: String,
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', {
|
||||||
|
type: 'button',
|
||||||
|
'aria-pressed': String(Boolean(props.modelValue)),
|
||||||
|
'data-stub': 'toggle-button',
|
||||||
|
onClick: () => emit('update:modelValue', !props.modelValue),
|
||||||
|
}, props.modelValue ? props.onLabel : props.offLabel)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const AutoCompleteStub = defineComponent({
|
||||||
|
name: 'AutoComplete',
|
||||||
|
props: {
|
||||||
|
modelValue: Array,
|
||||||
|
id: String,
|
||||||
|
multiple: Boolean,
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue', 'complete'],
|
||||||
|
setup(props, { attrs, emit }) {
|
||||||
|
return () => h('input', {
|
||||||
|
...attrs,
|
||||||
|
id: props.id,
|
||||||
|
value: (props.modelValue ?? []).join(','),
|
||||||
|
'data-stub': 'auto-complete',
|
||||||
|
onInput: (event: Event) => emit('update:modelValue', splitList((event.target as HTMLInputElement).value)),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const UrlListInputStub = defineComponent({
|
||||||
|
name: 'UrlListInput',
|
||||||
|
props: {
|
||||||
|
modelValue: Array,
|
||||||
|
id: String,
|
||||||
|
addLabel: String,
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { attrs, emit }) {
|
||||||
|
return () => h('input', {
|
||||||
|
...attrs,
|
||||||
|
id: props.id,
|
||||||
|
value: (props.modelValue ?? []).join(','),
|
||||||
|
'data-stub': 'url-list-input',
|
||||||
|
'data-add-label': props.addLabel,
|
||||||
|
onInput: (event: Event) => emit('update:modelValue', splitList((event.target as HTMLInputElement).value)),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const SelectButtonStub = defineComponent({
|
||||||
|
name: 'SelectButton',
|
||||||
|
props: {
|
||||||
|
modelValue: String,
|
||||||
|
options: Array,
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('select', {
|
||||||
|
value: props.modelValue,
|
||||||
|
'data-stub': 'select-button',
|
||||||
|
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value),
|
||||||
|
}, (props.options ?? []).map((option) => h('option', { value: option as string }, option as string)))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
name: 'Button',
|
||||||
|
props: {
|
||||||
|
label: String,
|
||||||
|
icon: String,
|
||||||
|
disabled: Boolean,
|
||||||
|
},
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { slots, emit }) {
|
||||||
|
return () => h('button', {
|
||||||
|
type: 'button',
|
||||||
|
disabled: props.disabled,
|
||||||
|
'data-label': props.label ?? props.icon,
|
||||||
|
onClick: (event: MouseEvent) => emit('click', event),
|
||||||
|
}, slots.default?.() ?? props.label ?? props.icon)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DialogStub = defineComponent({
|
||||||
|
name: 'Dialog',
|
||||||
|
props: {
|
||||||
|
visible: Boolean,
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () => h('div', { hidden: !props.visible, 'data-stub': 'dialog' }, [
|
||||||
|
slots.default?.(),
|
||||||
|
slots.footer?.(),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const AclManagerStub = defineComponent({
|
||||||
|
name: 'AclManager',
|
||||||
|
props: {
|
||||||
|
modelValue: Object,
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props) {
|
||||||
|
return () => h('pre', { 'data-stub': 'acl-manager' }, JSON.stringify(props.modelValue))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function makeConfig(): NetworkConfig {
|
||||||
|
const config = DEFAULT_NETWORK_CONFIG()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
dhcp: false,
|
||||||
|
virtual_ipv4: '10.1.2.3',
|
||||||
|
network_length: 24,
|
||||||
|
network_name: 'mesh-a',
|
||||||
|
network_secret: 'secret-a',
|
||||||
|
peer_urls: ['tcp://peer-a:11010', 'udp://peer-b:11010'],
|
||||||
|
latency_first: true,
|
||||||
|
use_smoltcp: true,
|
||||||
|
disable_ipv6: true,
|
||||||
|
no_tun: true,
|
||||||
|
hostname: 'host-a',
|
||||||
|
proxy_cidrs: ['10.10.0.0/16', '172.16.1.0/24'],
|
||||||
|
enable_vpn_portal: true,
|
||||||
|
vpn_portal_client_network_addr: '10.144.0.0',
|
||||||
|
vpn_portal_listen_port: 22023,
|
||||||
|
listener_urls: ['tcp://0.0.0.0:12010'],
|
||||||
|
dev_name: 'tun-test',
|
||||||
|
mtu: 1280,
|
||||||
|
instance_recv_bps_limit: '9007199254740993',
|
||||||
|
enable_relay_network_whitelist: true,
|
||||||
|
relay_network_whitelist: ['network-a'],
|
||||||
|
enable_manual_routes: true,
|
||||||
|
routes: ['192.168.0.0/16'],
|
||||||
|
enable_socks5: true,
|
||||||
|
socks5_port: 1086,
|
||||||
|
exit_nodes: ['exit-a'],
|
||||||
|
mapped_listeners: ['tcp://127.0.0.1:22000'],
|
||||||
|
port_forwards: [{
|
||||||
|
proto: 'udp',
|
||||||
|
bind_ip: '0.0.0.0',
|
||||||
|
bind_port: 18080,
|
||||||
|
dst_ip: '10.0.0.2',
|
||||||
|
dst_port: 8080,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountConfig(config: NetworkConfig = makeConfig()) {
|
||||||
|
const curNetwork = reactive(config) as NetworkConfig
|
||||||
|
const wrapper = mount(Config, {
|
||||||
|
props: {
|
||||||
|
curNetwork,
|
||||||
|
hostname: 'host-from-prop',
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
directives: {
|
||||||
|
tooltip: () => {},
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
AclManager: AclManagerStub,
|
||||||
|
AutoComplete: AutoCompleteStub,
|
||||||
|
Button: ButtonStub,
|
||||||
|
Checkbox: CheckboxStub,
|
||||||
|
Dialog: DialogStub,
|
||||||
|
Divider: DividerStub,
|
||||||
|
InputGroup: PassThrough,
|
||||||
|
InputGroupAddon: PassThrough,
|
||||||
|
InputNumber: InputNumberStub,
|
||||||
|
InputText: InputTextStub,
|
||||||
|
Panel: PanelStub,
|
||||||
|
Password: PasswordStub,
|
||||||
|
SelectButton: SelectButtonStub,
|
||||||
|
ToggleButton: ToggleButtonStub,
|
||||||
|
UrlListInput: UrlListInputStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { curNetwork, wrapper }
|
||||||
|
}
|
||||||
|
|
||||||
|
function input(wrapper: VueWrapper, selector: string): HTMLInputElement {
|
||||||
|
return wrapper.find(selector).element as HTMLInputElement
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setInput(wrapper: VueWrapper, selector: string, value: string) {
|
||||||
|
await wrapper.find(selector).setValue(value)
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Config.vue network config projection', () => {
|
||||||
|
it('projects config values into the visible form controls', async () => {
|
||||||
|
const { curNetwork, wrapper } = mountConfig()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(input(wrapper, '#network_name').value).toBe('mesh-a')
|
||||||
|
expect(input(wrapper, '#network_secret').value).toBe('secret-a')
|
||||||
|
expect(input(wrapper, '#virtual_ip').value).toBe('10.1.2.3')
|
||||||
|
expect(input(wrapper, '#initial_nodes').value).toBe('tcp://peer-a:11010,udp://peer-b:11010')
|
||||||
|
expect(input(wrapper, '#virtual_ip_auto').checked).toBe(false)
|
||||||
|
expect(input(wrapper, '#latency_first').checked).toBe(true)
|
||||||
|
expect(input(wrapper, '#use_smoltcp').checked).toBe(true)
|
||||||
|
expect(input(wrapper, '#disable_ipv6').checked).toBe(true)
|
||||||
|
expect(input(wrapper, '#no_tun').checked).toBe(true)
|
||||||
|
|
||||||
|
expect(input(wrapper, '#hostname').value).toBe('host-a')
|
||||||
|
expect(input(wrapper, '#subnet-proxy').value).toBe('10.10.0.0/16,172.16.1.0/24')
|
||||||
|
expect(input(wrapper, 'input[placeholder="vpn_portal_client_network"]').value).toBe('10.144.0.0')
|
||||||
|
expect(input(wrapper, '#dev_name').value).toBe('tun-test')
|
||||||
|
expect(input(wrapper, '#mtu').value).toBe('1280')
|
||||||
|
expect(input(wrapper, '#instance_recv_bps_limit').value).toBe('9007199254740993')
|
||||||
|
expect(input(wrapper, '#relay_network_whitelist').value).toBe('network-a')
|
||||||
|
expect(input(wrapper, '#routes').value).toBe('192.168.0.0/16')
|
||||||
|
expect(input(wrapper, '#socks5_port').value).toBe('1086')
|
||||||
|
expect(input(wrapper, '#exit_nodes').value).toBe('exit-a')
|
||||||
|
expect(input(wrapper, 'input[data-add-label="add_listener_url"]').value).toBe('tcp://0.0.0.0:12010')
|
||||||
|
expect(input(wrapper, 'input[data-add-label="add_mapped_listener"]').value).toBe('tcp://127.0.0.1:22000')
|
||||||
|
|
||||||
|
expect(wrapper.find<HTMLSelectElement>('select[data-stub="select-button"]').element.value).toBe('udp')
|
||||||
|
expect(input(wrapper, 'input[placeholder="port_forwards_bind_addr"]').value).toBe('0.0.0.0')
|
||||||
|
expect(input(wrapper, 'input[placeholder="port_forwards_dst_addr"]').value).toBe('10.0.0.2')
|
||||||
|
expect(wrapper.findComponent(AclManagerStub).props('modelValue')).toStrictEqual(curNetwork.acl)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('projects form edits back into config and backend JSON', async () => {
|
||||||
|
const { curNetwork, wrapper } = mountConfig()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
await wrapper.find('#virtual_ip_auto').setValue(false)
|
||||||
|
await setInput(wrapper, '#network_name', 'mesh-edited')
|
||||||
|
await setInput(wrapper, '#network_secret', 'secret-edited')
|
||||||
|
await setInput(wrapper, '#virtual_ip', '10.7.7.7')
|
||||||
|
await setInput(wrapper, '#initial_nodes', ' tcp://peer-x:11010, , udp://peer-y:11010 ')
|
||||||
|
await wrapper.find('#no_tun').setValue(false)
|
||||||
|
await wrapper.find('#disable_ipv6').setValue(false)
|
||||||
|
await setInput(wrapper, '#hostname', 'host-edited')
|
||||||
|
await setInput(wrapper, '#subnet-proxy', '10.7.0.0/16,172.17.0.0/16')
|
||||||
|
await setInput(wrapper, 'input[placeholder="vpn_portal_client_network"]', '10.200.0.0')
|
||||||
|
await setInput(wrapper, 'input[data-add-label="add_listener_url"]', 'tcp://0.0.0.0:13010')
|
||||||
|
await setInput(wrapper, '#dev_name', 'tun-edited')
|
||||||
|
await setInput(wrapper, '#mtu', '1260')
|
||||||
|
await setInput(wrapper, '#instance_recv_bps_limit', '9007199254740993')
|
||||||
|
await setInput(wrapper, '#relay_network_whitelist', 'network-edited')
|
||||||
|
await setInput(wrapper, '#routes', '192.168.10.0/24')
|
||||||
|
await setInput(wrapper, '#socks5_port', '1089')
|
||||||
|
await setInput(wrapper, '#exit_nodes', 'exit-edited')
|
||||||
|
await setInput(wrapper, 'input[data-add-label="add_mapped_listener"]', 'tcp://127.0.0.1:23000')
|
||||||
|
await wrapper.find('select[data-stub="select-button"]').setValue('tcp')
|
||||||
|
await setInput(wrapper, 'input[placeholder="port_forwards_bind_addr"]', '127.0.0.1')
|
||||||
|
await setInput(wrapper, 'input[placeholder="port_forwards_dst_addr"]', '10.9.0.2')
|
||||||
|
|
||||||
|
const portNumbers = wrapper.findAll<HTMLInputElement>('input#horizontal-buttons')
|
||||||
|
await portNumbers[1].setValue('19090')
|
||||||
|
await portNumbers[2].setValue('9090')
|
||||||
|
|
||||||
|
expect(curNetwork).toMatchObject({
|
||||||
|
dhcp: false,
|
||||||
|
virtual_ipv4: '10.7.7.7',
|
||||||
|
network_name: 'mesh-edited',
|
||||||
|
network_secret: 'secret-edited',
|
||||||
|
peer_urls: ['tcp://peer-x:11010', 'udp://peer-y:11010'],
|
||||||
|
no_tun: false,
|
||||||
|
disable_ipv6: false,
|
||||||
|
hostname: 'host-edited',
|
||||||
|
proxy_cidrs: ['10.7.0.0/16', '172.17.0.0/16'],
|
||||||
|
vpn_portal_client_network_addr: '10.200.0.0',
|
||||||
|
listener_urls: ['tcp://0.0.0.0:13010'],
|
||||||
|
dev_name: 'tun-edited',
|
||||||
|
mtu: 1260,
|
||||||
|
instance_recv_bps_limit: '9007199254740993',
|
||||||
|
relay_network_whitelist: ['network-edited'],
|
||||||
|
routes: ['192.168.10.0/24'],
|
||||||
|
socks5_port: 1089,
|
||||||
|
exit_nodes: ['exit-edited'],
|
||||||
|
mapped_listeners: ['tcp://127.0.0.1:23000'],
|
||||||
|
port_forwards: [{
|
||||||
|
proto: 'tcp',
|
||||||
|
bind_ip: '127.0.0.1',
|
||||||
|
bind_port: 19090,
|
||||||
|
dst_ip: '10.9.0.2',
|
||||||
|
dst_port: 9090,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
|
||||||
|
const backend = toBackendNetworkConfig(curNetwork)
|
||||||
|
expect(backend).toMatchObject({
|
||||||
|
virtual_ipv4: '10.7.7.7',
|
||||||
|
network_name: 'mesh-edited',
|
||||||
|
network_secret: 'secret-edited',
|
||||||
|
peer_urls: ['tcp://peer-x:11010', 'udp://peer-y:11010'],
|
||||||
|
listener_urls: ['tcp://0.0.0.0:13010'],
|
||||||
|
mtu: 1260,
|
||||||
|
instance_recv_bps_limit: '9007199254740993',
|
||||||
|
port_forwards: [{
|
||||||
|
proto: 'tcp',
|
||||||
|
bind_ip: '127.0.0.1',
|
||||||
|
bind_port: 19090,
|
||||||
|
dst_ip: '10.9.0.2',
|
||||||
|
dst_port: 9090,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips every visible boolean config control into backend JSON', async () => {
|
||||||
|
const config = makeConfig()
|
||||||
|
const originalFlagValues = new Map(
|
||||||
|
CONFIG_UI_BOOLEAN_FIELDS.map((field, index) => {
|
||||||
|
const value = index % 2 === 0
|
||||||
|
config[field] = value
|
||||||
|
return [field, value]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { curNetwork, wrapper } = mountConfig(config)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
for (const [field, selector] of CONFIG_CHECKBOX_FIELDS) {
|
||||||
|
const value = originalFlagValues.get(field)
|
||||||
|
expect(input(wrapper, selector).checked, `${field} should project into UI`).toBe(value)
|
||||||
|
await wrapper.find(selector).setValue(!value)
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleButtons = wrapper.findAll('button[data-stub="toggle-button"]')
|
||||||
|
expect(toggleButtons).toHaveLength(CONFIG_TOGGLE_FIELDS.length)
|
||||||
|
for (const [index, field] of CONFIG_TOGGLE_FIELDS.entries()) {
|
||||||
|
const value = originalFlagValues.get(field)
|
||||||
|
expect(toggleButtons[index].attributes('aria-pressed'), `${field} should project into UI`)
|
||||||
|
.toBe(String(value))
|
||||||
|
await toggleButtons[index].trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
const backend = toBackendNetworkConfig(curNetwork) as Record<string, unknown>
|
||||||
|
for (const [field, value] of originalFlagValues) {
|
||||||
|
const expectedValue = !value
|
||||||
|
expect(curNetwork[field], `${field} should update config`).toBe(expectedValue)
|
||||||
|
expect(backend[field], `${field} should be preserved in backend JSON`).toBe(expectedValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps uint64 input editable without losing large values', async () => {
|
||||||
|
const { curNetwork, wrapper } = mountConfig()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
await setInput(wrapper, '#instance_recv_bps_limit', '1234')
|
||||||
|
expect(curNetwork.instance_recv_bps_limit).toBe(1234)
|
||||||
|
|
||||||
|
await setInput(wrapper, '#instance_recv_bps_limit', 'not-a-number')
|
||||||
|
expect(curNetwork.instance_recv_bps_limit).toBe(1234)
|
||||||
|
|
||||||
|
await setInput(wrapper, '#instance_recv_bps_limit', '0')
|
||||||
|
expect(curNetwork.instance_recv_bps_limit).toBeNull()
|
||||||
|
expect(input(wrapper, '#instance_recv_bps_limit').value).toBe('')
|
||||||
|
|
||||||
|
await setInput(wrapper, '#instance_recv_bps_limit', '9007199254740993')
|
||||||
|
expect(curNetwork.instance_recv_bps_limit).toBe('9007199254740993')
|
||||||
|
|
||||||
|
await setInput(wrapper, '#instance_recv_bps_limit', '18446744073709551616')
|
||||||
|
expect(curNetwork.instance_recv_bps_limit).toBe('9007199254740993')
|
||||||
|
|
||||||
|
await setInput(wrapper, '#instance_recv_bps_limit', '')
|
||||||
|
expect(curNetwork.instance_recv_bps_limit).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits runNetwork with the current projected config', async () => {
|
||||||
|
const { curNetwork, wrapper } = mountConfig()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
await setInput(wrapper, '#network_name', 'mesh-running')
|
||||||
|
await wrapper.find('button[data-label="run_network"]').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('runNetwork')?.[0]).toEqual([curNetwork])
|
||||||
|
expect((wrapper.emitted('runNetwork')?.[0][0] as NetworkConfig).network_name).toBe('mesh-running')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import RemoteManagement from '../src/components/RemoteManagement.vue'
|
||||||
|
import {
|
||||||
|
DEFAULT_NETWORK_CONFIG,
|
||||||
|
type NetworkConfig,
|
||||||
|
} from '../src/types/network'
|
||||||
|
|
||||||
|
const BOOLEAN_CONFIG_FIELDS = [
|
||||||
|
'dhcp',
|
||||||
|
'enable_vpn_portal',
|
||||||
|
'advanced_settings',
|
||||||
|
'latency_first',
|
||||||
|
'use_smoltcp',
|
||||||
|
'disable_ipv6',
|
||||||
|
'enable_kcp_proxy',
|
||||||
|
'disable_kcp_input',
|
||||||
|
'disable_p2p',
|
||||||
|
'bind_device',
|
||||||
|
'no_tun',
|
||||||
|
'enable_exit_node',
|
||||||
|
'relay_all_peer_rpc',
|
||||||
|
'multi_thread',
|
||||||
|
'enable_relay_network_whitelist',
|
||||||
|
'enable_manual_routes',
|
||||||
|
'proxy_forward_by_system',
|
||||||
|
'disable_encryption',
|
||||||
|
'enable_socks5',
|
||||||
|
'disable_udp_hole_punching',
|
||||||
|
'enable_magic_dns',
|
||||||
|
'enable_private_mode',
|
||||||
|
'enable_quic_proxy',
|
||||||
|
'disable_quic_input',
|
||||||
|
'disable_sym_hole_punching',
|
||||||
|
'p2p_only',
|
||||||
|
'lazy_p2p',
|
||||||
|
'need_p2p',
|
||||||
|
'disable_upnp',
|
||||||
|
'ipv6_public_addr_provider',
|
||||||
|
'ipv6_public_addr_auto',
|
||||||
|
'disable_relay_data',
|
||||||
|
'enable_udp_broadcast_relay',
|
||||||
|
'disable_tcp_hole_punching',
|
||||||
|
] as const satisfies readonly (keyof NetworkConfig)[]
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('primevue', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
|
||||||
|
const PassThrough = defineComponent({
|
||||||
|
name: 'PassThrough',
|
||||||
|
props: {
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () => h('div', {
|
||||||
|
'data-label': props.label,
|
||||||
|
'data-value': props.value,
|
||||||
|
'data-stub': 'pass-through',
|
||||||
|
}, slots.default?.())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
name: 'Button',
|
||||||
|
props: {
|
||||||
|
label: String,
|
||||||
|
icon: String,
|
||||||
|
disabled: Boolean,
|
||||||
|
},
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { slots, emit }) {
|
||||||
|
return () => h('button', {
|
||||||
|
type: 'button',
|
||||||
|
disabled: props.disabled,
|
||||||
|
'data-label': props.label ?? props.icon,
|
||||||
|
onClick: (event: MouseEvent) => emit('click', event),
|
||||||
|
}, slots.default?.() ?? props.label ?? props.icon)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const SelectStub = defineComponent({
|
||||||
|
name: 'Select',
|
||||||
|
props: {
|
||||||
|
modelValue: Object,
|
||||||
|
options: Array,
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () => h('div', { 'data-stub': 'select' }, [
|
||||||
|
slots.value?.({ value: props.modelValue, placeholder: '' }),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const MenuStub = defineComponent({
|
||||||
|
name: 'Menu',
|
||||||
|
setup(_, { expose }) {
|
||||||
|
expose({ toggle: vi.fn() })
|
||||||
|
return () => h('div', { 'data-stub': 'menu' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
Button: ButtonStub,
|
||||||
|
ConfirmPopup: PassThrough,
|
||||||
|
Divider: PassThrough,
|
||||||
|
IftaLabel: PassThrough,
|
||||||
|
Menu: MenuStub,
|
||||||
|
Message: PassThrough,
|
||||||
|
Select: SelectStub,
|
||||||
|
Tag: PassThrough,
|
||||||
|
useConfirm: () => ({ require: vi.fn() }),
|
||||||
|
useToast: () => ({ add: vi.fn() }),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const INSTANCE_ID = '00000000-0000-0000-0000-000000000001'
|
||||||
|
const INSTANCE_UUID = {
|
||||||
|
part1: 0,
|
||||||
|
part2: 0,
|
||||||
|
part3: 0,
|
||||||
|
part4: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFlagConfig(): NetworkConfig {
|
||||||
|
const config = {
|
||||||
|
...DEFAULT_NETWORK_CONFIG(),
|
||||||
|
instance_id: INSTANCE_ID,
|
||||||
|
network_name: 'mesh-save',
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOLEAN_CONFIG_FIELDS.forEach((field, index) => {
|
||||||
|
config[field] = index % 2 === 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneConfig(config: NetworkConfig): NetworkConfig {
|
||||||
|
return JSON.parse(JSON.stringify(config)) as NetworkConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotBooleanConfigFields(config: NetworkConfig): Record<string, unknown> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
BOOLEAN_CONFIG_FIELDS.map((field) => [field, config[field]]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleRemoteManagement() {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RemoteManagement config save', () => {
|
||||||
|
it('saves the current network config without dropping boolean fields', async () => {
|
||||||
|
const config = makeFlagConfig()
|
||||||
|
const expectedFlags = snapshotBooleanConfigFields(config)
|
||||||
|
const api = {
|
||||||
|
delete_network: vi.fn(),
|
||||||
|
generate_config: vi.fn(),
|
||||||
|
get_network_config: vi.fn(async () => cloneConfig(config)),
|
||||||
|
get_network_info: vi.fn(),
|
||||||
|
get_network_metas: vi.fn(async (instanceIds: string[]) => ({
|
||||||
|
metas: Object.fromEntries(instanceIds.map((id) => [id, {
|
||||||
|
config_permission: 0xffffffff,
|
||||||
|
inst_id: INSTANCE_UUID,
|
||||||
|
instance_name: 'mesh-save',
|
||||||
|
network_name: 'mesh-save',
|
||||||
|
source: 2,
|
||||||
|
}])),
|
||||||
|
})),
|
||||||
|
list_network_instance_ids: vi.fn(async () => ({
|
||||||
|
disabled_inst_ids: [INSTANCE_UUID],
|
||||||
|
running_inst_ids: [],
|
||||||
|
})),
|
||||||
|
parse_config: vi.fn(),
|
||||||
|
run_network: vi.fn(),
|
||||||
|
save_config: vi.fn(async () => undefined),
|
||||||
|
update_network_instance_state: vi.fn(),
|
||||||
|
validate_config: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapper = mount(RemoteManagement, {
|
||||||
|
props: {
|
||||||
|
api,
|
||||||
|
instanceId: INSTANCE_ID,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Config: true,
|
||||||
|
ConfigEditDialog: true,
|
||||||
|
Status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await settleRemoteManagement()
|
||||||
|
|
||||||
|
const saveButton = wrapper.find('button[data-label="web.device_management.save_config"]')
|
||||||
|
expect(saveButton.exists()).toBe(true)
|
||||||
|
expect(saveButton.attributes('disabled')).toBeUndefined()
|
||||||
|
|
||||||
|
await saveButton.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(api.save_config).toHaveBeenCalledOnce()
|
||||||
|
const savedConfig = api.save_config.mock.calls[0][0] as NetworkConfig
|
||||||
|
|
||||||
|
for (const field of BOOLEAN_CONFIG_FIELDS) {
|
||||||
|
expect(savedConfig[field], `${field} should be saved`).toBe(expectedFlags[field])
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
wrapper.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
class ResizeObserverStub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import ViteYaml from '@modyfi/vite-plugin-yaml'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), ViteYaml()],
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
include: ['tests/**/*.spec.ts'],
|
||||||
|
setupFiles: ['./tests/setup.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "pnpm --dir ../frontend-lib build && vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "pnpm --dir ../frontend-lib build && vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ once_cell = "1.18.0"
|
|||||||
|
|
||||||
# for rpc
|
# for rpc
|
||||||
prost = "0.14.3"
|
prost = "0.14.3"
|
||||||
prost-reflect = { version = "0.16.4", default-features = false, features = ["derive"] }
|
prost-reflect = { version = "0.16.4", default-features = false, features = ["derive", "serde"] }
|
||||||
prost-wkt-types = "0.7.1"
|
prost-wkt-types = "0.7.1"
|
||||||
pbjson = "0.9.0"
|
pbjson = "0.9.0"
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use ariadne::{CharSet, Config as AriadneConfig, IndexType, Label, Report, Report
|
|||||||
use base64::{Engine as _, prelude::BASE64_STANDARD};
|
use base64::{Engine as _, prelude::BASE64_STANDARD};
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
use clap::builder::PossibleValue;
|
use clap::builder::PossibleValue;
|
||||||
|
use prost_reflect::{DynamicMessage, ReflectMessage, SerializeOptions};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use strum::{Display, EnumString, VariantArray};
|
use strum::{Display, EnumString, VariantArray};
|
||||||
use tokio::io::AsyncReadExt as _;
|
use tokio::io::AsyncReadExt as _;
|
||||||
@@ -78,6 +79,54 @@ pub fn gen_default_flags() -> Flags {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn flags_to_dynamic_message(flags: &Flags) -> DynamicMessage {
|
||||||
|
let mut message = DynamicMessage::new(flags.descriptor());
|
||||||
|
message
|
||||||
|
.transcode_from(flags)
|
||||||
|
.expect("FlagsInConfig should transcode to DynamicMessage");
|
||||||
|
message
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flags_to_full_json_map(flags: &DynamicMessage) -> serde_json::Map<String, serde_json::Value> {
|
||||||
|
let options = SerializeOptions::new()
|
||||||
|
.use_proto_field_name(true)
|
||||||
|
.skip_default_fields(false);
|
||||||
|
|
||||||
|
match flags
|
||||||
|
.serialize_with_options(serde_json::value::Serializer, &options)
|
||||||
|
.expect("FlagsInConfig should serialize to JSON")
|
||||||
|
{
|
||||||
|
serde_json::Value::Object(map) => map,
|
||||||
|
_ => unreachable!("FlagsInConfig should serialize to a JSON object"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flags_diff_from_default(flags: &Flags) -> serde_json::Map<String, serde_json::Value> {
|
||||||
|
let default_flags = gen_default_flags();
|
||||||
|
let default_message = flags_to_dynamic_message(&default_flags);
|
||||||
|
let current_message = flags_to_dynamic_message(flags);
|
||||||
|
let default_map = flags_to_full_json_map(&default_message);
|
||||||
|
let current_map = flags_to_full_json_map(¤t_message);
|
||||||
|
|
||||||
|
current_message
|
||||||
|
.descriptor()
|
||||||
|
.fields()
|
||||||
|
.filter_map(|field| {
|
||||||
|
let key = field.name();
|
||||||
|
let value_changed = default_map.get(key) != current_map.get(key);
|
||||||
|
let presence_changed =
|
||||||
|
default_message.has_field(&field) != current_message.has_field(&field);
|
||||||
|
if value_changed || presence_changed {
|
||||||
|
current_map
|
||||||
|
.get(key)
|
||||||
|
.map(|value| (key.to_string(), value.clone()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn mapped_listener_allows_implicit_port(url: &url::Url) -> bool {
|
fn mapped_listener_allows_implicit_port(url: &url::Url) -> bool {
|
||||||
TunnelScheme::try_from(url)
|
TunnelScheme::try_from(url)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -1093,28 +1142,9 @@ impl ConfigLoader for TomlConfigLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn dump(&self) -> String {
|
fn dump(&self) -> String {
|
||||||
let default_flags_json = serde_json::to_string(&gen_default_flags()).unwrap();
|
|
||||||
let default_flags_hashmap =
|
|
||||||
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&default_flags_json)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let cur_flags_json = serde_json::to_string(&self.get_flags()).unwrap();
|
|
||||||
let cur_flags_hashmap =
|
|
||||||
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&cur_flags_json)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut flag_map: serde_json::Map<String, serde_json::Value> = Default::default();
|
|
||||||
for (key, value) in default_flags_hashmap {
|
|
||||||
if let Some(v) = cur_flags_hashmap.get(&key)
|
|
||||||
&& *v != value
|
|
||||||
{
|
|
||||||
flag_map.insert(key, v.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut config = self.config.lock().unwrap().clone();
|
let mut config = self.config.lock().unwrap().clone();
|
||||||
Self::normalize_config_source(&mut config);
|
Self::normalize_config_source(&mut config);
|
||||||
config.flags = Some(flag_map);
|
config.flags = Some(flags_diff_from_default(&self.get_flags()));
|
||||||
if config.stun_servers == Some(StunInfoCollector::get_default_servers()) {
|
if config.stun_servers == Some(StunInfoCollector::get_default_servers()) {
|
||||||
config.stun_servers = None;
|
config.stun_servers = None;
|
||||||
}
|
}
|
||||||
@@ -1460,6 +1490,49 @@ socket_mark = 66
|
|||||||
assert_eq!(cfg.get_flags().socket_mark, None);
|
assert_eq!(cfg.get_flags().socket_mark, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dump_preserves_flags_that_differ_from_easytier_defaults() {
|
||||||
|
let cfg = TomlConfigLoader::default();
|
||||||
|
let mut flags = gen_default_flags();
|
||||||
|
flags.dev_name = "et_test".to_string();
|
||||||
|
flags.enable_quic_proxy = true;
|
||||||
|
flags.disable_tcp_hole_punching = true;
|
||||||
|
flags.disable_sym_hole_punching = true;
|
||||||
|
flags.multi_thread = false;
|
||||||
|
flags.bind_device = false;
|
||||||
|
flags.enable_ipv6 = false;
|
||||||
|
flags.relay_network_whitelist = "".to_string();
|
||||||
|
flags.mtu = 0;
|
||||||
|
flags.socket_mark = Some(0);
|
||||||
|
cfg.set_flags(flags);
|
||||||
|
|
||||||
|
let dumped = cfg.dump();
|
||||||
|
|
||||||
|
assert!(dumped.contains("dev_name = \"et_test\""));
|
||||||
|
assert!(dumped.contains("enable_quic_proxy = true"));
|
||||||
|
assert!(dumped.contains("disable_tcp_hole_punching = true"));
|
||||||
|
assert!(dumped.contains("disable_sym_hole_punching = true"));
|
||||||
|
assert!(dumped.contains("multi_thread = false"));
|
||||||
|
assert!(dumped.contains("bind_device = false"));
|
||||||
|
assert!(dumped.contains("enable_ipv6 = false"));
|
||||||
|
assert!(dumped.contains("relay_network_whitelist = \"\""));
|
||||||
|
assert!(dumped.contains("mtu = 0"));
|
||||||
|
assert!(dumped.contains("socket_mark = 0"));
|
||||||
|
|
||||||
|
let reloaded = TomlConfigLoader::new_from_str(&dumped).unwrap();
|
||||||
|
let reloaded_flags = reloaded.get_flags();
|
||||||
|
assert_eq!(reloaded_flags.dev_name, "et_test");
|
||||||
|
assert!(reloaded_flags.enable_quic_proxy);
|
||||||
|
assert!(reloaded_flags.disable_tcp_hole_punching);
|
||||||
|
assert!(reloaded_flags.disable_sym_hole_punching);
|
||||||
|
assert!(!reloaded_flags.multi_thread);
|
||||||
|
assert!(!reloaded_flags.bind_device);
|
||||||
|
assert!(!reloaded_flags.enable_ipv6);
|
||||||
|
assert_eq!(reloaded_flags.relay_network_whitelist, "");
|
||||||
|
assert_eq!(reloaded_flags.mtu, 0);
|
||||||
|
assert_eq!(reloaded_flags.socket_mark, Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_stun_servers_config() {
|
fn test_stun_servers_config() {
|
||||||
let config = TomlConfigLoader::default();
|
let config = TomlConfigLoader::default();
|
||||||
|
|||||||
@@ -1210,6 +1210,32 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn network_config_dump_preserves_web_flags() -> Result<(), anyhow::Error> {
|
||||||
|
let network_config = super::NetworkConfig {
|
||||||
|
instance_id: Some(uuid::Uuid::new_v4().to_string()),
|
||||||
|
dhcp: Some(true),
|
||||||
|
network_name: Some("demo".to_string()),
|
||||||
|
network_secret: Some("secret".to_string()),
|
||||||
|
networking_method: Some(crate::proto::api::manage::NetworkingMethod::Manual as i32),
|
||||||
|
peer_urls: vec!["tcp://1.2.3.4:11010".to_string()],
|
||||||
|
listener_urls: vec!["tcp://0.0.0.0:11010".to_string()],
|
||||||
|
dev_name: Some("et_test".to_string()),
|
||||||
|
enable_quic_proxy: Some(true),
|
||||||
|
disable_tcp_hole_punching: Some(true),
|
||||||
|
disable_sym_hole_punching: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let dumped = network_config.gen_config()?.dump();
|
||||||
|
|
||||||
|
assert!(dumped.contains("dev_name = \"et_test\""));
|
||||||
|
assert!(dumped.contains("enable_quic_proxy = true"));
|
||||||
|
assert!(dumped.contains("disable_tcp_hole_punching = true"));
|
||||||
|
assert!(dumped.contains("disable_sym_hole_punching = true"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_network_config_conversion_random() -> Result<(), anyhow::Error> {
|
fn test_network_config_conversion_random() -> Result<(), anyhow::Error> {
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"happy-dom": "16.8.1",
|
||||||
"minimatch": "10.2.4"
|
"minimatch": "10.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+763
-23
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user