Compare commits

...

3 Commits

Author SHA1 Message Date
eeedce5b5c refactor(layout): Optimize the application's main layout structure. 2025-12-26 17:32:21 +08:00
57f29f855b feat(nodeEdit): Add node configurator and schema field components
- Introduced the NodeConfigurator component for node configuration
- Added the SchemaField component for schema field rendering
- Changed node parameters to an object configuration model
- Added error handling and notification mechanisms
- Modified the CoreTypes definition method to avoid Proxy issues
2025-12-26 17:27:36 +08:00
b085ba3c60 feat(core): Added node configuration schema support
- Imported the ConfigurationSchema class to define core type configurations
- Added CoreTypes constant support for xray and v2fly configuration modes
- Replaced parameter inputs in addNode.vue with node type selectors
- Created the nodeConfigurator.vue component to handle configuration structure
- Added Zod mode validation support for xray and v2fly configurations
- Updated the README document to explain the project build and development startup steps
- Added MCP HTTP service support tool list and state control interface
- Imported the Rocket.rs dependency and configured the background service runtime environment
2025-11-30 23:36:48 +08:00
16 changed files with 1011 additions and 31 deletions

View File

@@ -1,7 +1,13 @@
# Tauri + Vue + TypeScript # Spary
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. Proxy software launcher
## Recommended IDE Setup ## Build
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) ### Generate icons
```shell
yarn tauri icon src/assets/logo.svg
```
### Start For Dev

2
api/api.http Normal file
View File

@@ -0,0 +1,2 @@
# 获取开关状态
GET 127.0.0.1:3000/mcp/tools/list

View File

@@ -27,3 +27,6 @@ tauri-plugin-fs = "2"
tauri-plugin-os = "2" tauri-plugin-os = "2"
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
rocket = { version = "0.5", features = ["json"] }
tokio = { version = "1.0", features = ["full"] }
uuid = { version = "1.0", features = ["v4", "serde"] }

View File

@@ -1,9 +1,12 @@
use tauri::Manager;
use crate::spary::spary_switch; use crate::spary::spary_switch;
use std::sync::Arc;
use tauri::menu::{Menu, MenuItem}; use tauri::menu::{Menu, MenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::Manager;
use tauri_plugin_sql::{Migration, MigrationKind}; use tauri_plugin_sql::{Migration, MigrationKind};
use tokio::sync::Mutex;
mod mcp_server;
mod spary; mod spary;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -71,8 +74,60 @@ pub fn run() {
}) })
.build(app)?; .build(app)?;
tray.set_menu(Some(menu))?; tray.set_menu(Some(menu))?;
// Start the MCP HTTP server in a background task
let app_handle = app.handle().clone();
std::thread::spawn(move || {
start_mcp_server(app_handle);
});
Ok(()) Ok(())
}) })
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
fn start_mcp_server(app_handle: tauri::AppHandle) {
// Disable MCP server for now
if false {
return;
}
use mcp_server::AppState;
// Create the shared application state
let state = AppState {
app_handle,
spray_status: Arc::new(Mutex::new(false)), // Initial spray status is off
};
// Configure Rocket with custom settings
let rocket = rocket::build()
.configure(rocket::Config {
port: 3000,
address: "127.0.0.1".parse().expect("Invalid address"),
..rocket::Config::default()
})
.manage(state)
.mount(
"/",
rocket::routes![
mcp_server::initialize,
mcp_server::get_capabilities,
mcp_server::list_tools,
mcp_server::call_tool,
mcp_server::set_spray_status,
mcp_server::get_spray_status
],
);
println!("MCP server running on http://127.0.0.1:3000");
// Start the server
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
rocket.launch().await.unwrap();
});
}

181
src-tauri/src/mcp_server.rs Normal file
View File

@@ -0,0 +1,181 @@
use rocket::{State, serde::{Deserialize, Serialize, json::Json}};
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::Mutex;
use tauri::AppHandle;
use crate::spary::spary_switch;
#[derive(Clone)]
pub struct AppState {
pub app_handle: AppHandle,
pub spray_status: Arc<Mutex<bool>>,
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
pub struct InitializeResponse {
protocol_version: String,
server_info: ServerInfo,
capabilities: Capabilities,
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
pub struct ServerInfo {
name: String,
version: String,
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
pub struct Capabilities {
experimental: Option<Value>,
tools: Option<Value>,
}
#[rocket::post("/mcp/initialize", data = "<_data>")]
pub async fn initialize(_state: &State<AppState>, _data: Json<Value>) -> Json<InitializeResponse> {
Json(InitializeResponse {
protocol_version: "1.0".to_string(),
server_info: ServerInfo {
name: "spary-mcp-server".to_string(),
version: "0.1.0".to_string(),
},
capabilities: Capabilities {
experimental: None,
tools: Some(json!({
"list": true,
"call": true
})),
},
})
}
#[rocket::get("/mcp/capabilities")]
pub async fn get_capabilities(_state: &State<AppState>) -> Json<Value> {
Json(json!({
"tools": {
"list": true,
"call": true
}
}))
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
struct Tool {
name: String,
description: String,
input_schema: Value,
}
#[rocket::get("/mcp/tools/list")]
pub async fn list_tools(_state: &State<AppState>) -> Json<Value> {
let tools = vec![
Tool {
name: "spray_toggle".to_string(),
description: "Toggle the spray functionality on or off".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"status": {
"type": "boolean",
"description": "Whether to turn spray on (true) or off (false)"
}
},
"required": ["status"]
}),
}
];
Json(json!({ "tools": tools }))
}
#[derive(Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct ToolCallRequest {
name: String,
arguments: Value,
}
#[rocket::post("/mcp/tools/call", data = "<payload>")]
pub async fn call_tool(
state: &State<AppState>,
payload: Json<ToolCallRequest>,
) -> Result<Json<Value>, rocket::http::Status> {
match payload.name.as_str() {
"spray_toggle" => {
if let Some(status) = payload.arguments.get("status").and_then(|v| v.as_bool()) {
// Call the existing spray_switch function
spary_switch(status);
// Update the spray status in the shared state
{
let mut spray_status = state.spray_status.lock().await;
*spray_status = status;
}
Ok(Json(json!({
"result": {
"success": true,
"message": format!("Spray toggled to {}", status)
}
})))
} else {
Err(rocket::http::Status::BadRequest)
}
}
_ => Err(rocket::http::Status::NotFound),
}
}
#[derive(Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct SprayRequest {
status: bool,
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
pub struct SprayResponse {
success: bool,
message: String,
}
#[rocket::post("/v1/spray", data = "<payload>")]
pub async fn set_spray_status(
state: &State<AppState>,
payload: Json<SprayRequest>,
) -> Json<SprayResponse> {
// Call the existing spray_switch function
spary_switch(payload.status);
// Update the spray status in the shared state
{
let mut spray_status = state.spray_status.lock().await;
*spray_status = payload.status;
}
Json(SprayResponse {
success: true,
message: format!("Spray toggled to {}", payload.status),
})
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
pub struct SprayStatusResponse {
status: bool,
}
#[rocket::get("/v1/spray")]
pub async fn get_spray_status(
state: &State<AppState>,
) -> Json<SprayStatusResponse> {
let spray_status = state.spray_status.lock().await;
Json(SprayStatusResponse {
status: *spray_status,
})
}

View File

@@ -1,13 +1,13 @@
<template> <template>
<v-app> <v-app>
<notification-provider/> <notification-provider/>
<v-main> <v-main class="d-flex" style="height: 100vh;">
<v-card class="fill-height"> <v-card class="d-flex flex-column" style="height: 100%; width: 100%;">
<v-layout class="fill-height"> <v-layout class="d-flex flex-column" style="height: 100%;">
<main-drawer/> <main-drawer/>
<v-main style="height: 100vh"> <v-card class="flex-grow-1 overflow-y-auto ma-4">
<router-view/> <router-view/>
</v-main> </v-card>
</v-layout> </v-layout>
</v-card> </v-card>
</v-main> </v-main>

2
src/components.d.ts vendored
View File

@@ -13,11 +13,13 @@ declare module 'vue' {
LanguageSwitcher: typeof import('./components/LanguageSwitcher.vue')['default'] LanguageSwitcher: typeof import('./components/LanguageSwitcher.vue')['default']
MainConsole: typeof import('./components/index/mainConsole.vue')['default'] MainConsole: typeof import('./components/index/mainConsole.vue')['default']
MainDrawer: typeof import('./components/index/mainDrawer.vue')['default'] MainDrawer: typeof import('./components/index/mainDrawer.vue')['default']
NodeConfigurator: typeof import('./components/nodeEdit/nodeConfigurator.vue')['default']
NodeList: typeof import('./components/nodeEdit/nodeList.vue')['default'] NodeList: typeof import('./components/nodeEdit/nodeList.vue')['default']
NodesFloatButton: typeof import('./components/nodeEdit/nodesFloatButton.vue')['default'] NodesFloatButton: typeof import('./components/nodeEdit/nodesFloatButton.vue')['default']
NotificationProvider: typeof import('./components/notify/notificationProvider.vue')['default'] NotificationProvider: typeof import('./components/notify/notificationProvider.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SchemaField: typeof import('./components/nodeEdit/SchemaField.vue')['default']
Spary: typeof import('./components/index/spary.vue')['default'] Spary: typeof import('./components/index/spary.vue')['default']
} }
} }

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import {computed, toRaw} from 'vue';
import { z, ZodObject, ZodString, ZodNumber, ZodBoolean, ZodEnum, ZodOptional } from 'zod';
defineOptions({
name: 'SchemaField'
})
const props = defineProps<{
schema: z.ZodTypeAny;
modelValue: any;
label: string;
}>();
// 使用toRaw获取原始schema对象避免Proxy问题
const rawSchema = toRaw(props.schema);
const emit = defineEmits(['update:modelValue']);
const isOptional = computed(() => rawSchema.optional());
const value = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
const typeName = computed(() => {
if (rawSchema instanceof ZodObject) return 'ZodObject';
if (rawSchema instanceof ZodString) return 'ZodString';
if (rawSchema instanceof ZodNumber) return 'ZodNumber';
if (rawSchema instanceof ZodBoolean) return 'ZodBoolean';
if (rawSchema instanceof ZodEnum) return 'ZodEnum';
if (rawSchema instanceof ZodOptional) return 'ZodOptional';
// Fallback for unknown types or types not handled explicitly
return 'Unknown';
});
const objectValue = computed(() => value.value as Record<string, any> | null);
const objectSchema = computed(() => {
const schema = rawSchema;
if (schema instanceof ZodOptional) {
const unwrapped = schema.unwrap();
if (unwrapped instanceof ZodObject) {
return unwrapped;
}
}
if (schema instanceof ZodObject) {
return schema;
}
return null;
});
</script>
<template>
<div class="schema-field">
<template v-if="typeName === 'ZodString'">
<v-text-field
v-model="value"
:label="label"
:hint="isOptional ? 'Optional' : ''"
persistent-hint
></v-text-field>
</template>
<template v-else-if="typeName === 'ZodNumber'">
<v-text-field
v-model.number="value"
type="number"
:label="label"
:hint="isOptional ? 'Optional' : ''"
persistent-hint
></v-text-field>
</template>
<template v-else-if="typeName === 'ZodBoolean'">
<v-switch
v-model="value"
:label="label"
color="primary"
:hint="isOptional ? 'Optional' : ''"
persistent-hint
></v-switch>
</template>
<template v-else-if="typeName === 'ZodEnum'">
<v-select
v-model="value"
:items="(rawSchema as ZodEnum<any>).options"
:label="label"
:hint="isOptional ? 'Optional' : ''"
persistent-hint
></v-select>
</template>
<template v-else-if="objectSchema">
<v-card variant="outlined" class="pa-4 mb-4">
<div class="text-subtitle-1 font-weight-medium">{{ label }}</div>
<div v-for="(field, key) in objectSchema.shape" :key="key">
<SchemaField
:schema="field"
:label="key.toString()"
:model-value="objectValue ? objectValue[key] : undefined"
@update:modelValue="newValue => {
const newObjectValue = { ...value.value };
newObjectValue[key] = newValue;
value.value = newObjectValue;
}"
/>
</div>
</v-card>
</template>
<template v-else-if="typeName === 'ZodOptional'">
<SchemaField
:schema="(rawSchema as ZodOptional<any>).unwrap()"
v-model="value"
:label="label"
/>
</template>
<template v-else>
<p class="text-caption">Unsupported type: {{ typeName }} for {{label}}</p>
</template>
</div>
</template>
<style scoped>
.schema-field {
margin-bottom: 1rem;
}
</style>

View File

@@ -4,6 +4,9 @@ import { useI18n } from 'vue-i18n';
import {Group, groupRepository} from "@/entities/group.ts"; import {Group, groupRepository} from "@/entities/group.ts";
import {nodeRepository} from "@/entities/node.ts"; import {nodeRepository} from "@/entities/node.ts";
import {notify} from "@/components/notify/notifyStore.ts"; import {notify} from "@/components/notify/notifyStore.ts";
import {ConfigurationSchema} from "@/utils/core/configurator/schema/schema.ts";
import {CoreTypes} from "@/utils/core/CoreDef.ts";
import NodeConfigurator from "@/components/nodeEdit/nodeConfigurator.vue";
const { t } = useI18n() const { t } = useI18n()
@@ -11,6 +14,8 @@ const props = defineProps<{ groupId: string }>()
const allGroups = ref<Group[]>([]) const allGroups = ref<Group[]>([])
const selectedGroupId = ref<number | null>(null) const selectedGroupId = ref<number | null>(null)
const configurationSchema= ref<ConfigurationSchema | null>(null)
const nodeConfig = ref<object | null>(null)
const loadGroups = async () => { const loadGroups = async () => {
allGroups.value = await groupRepository.findAll() allGroups.value = await groupRepository.findAll()
@@ -21,33 +26,47 @@ const loadGroups = async () => {
onMounted(loadGroups) onMounted(loadGroups)
const nodeAlias = ref('') const nodeAlias = ref('')
const nodeArguments = ref<string | null>(null)
const isAdding = ref(false) const isAdding = ref(false)
const isAddDisabled = computed(() => { const isAddDisabled = computed(() => {
return !nodeAlias.value || nodeAlias.value.length < 3 || nodeAlias.value.length > 15 || isAdding.value return !nodeAlias.value || nodeAlias.value.length < 3 || nodeAlias.value.length > 15 || isAdding.value
}) })
function onConfigurationSchemaChange(value: any) {
console.log('Configuration schema changed:', value);
// 确保设置的值是正确的对象结构
configurationSchema.value = value;
}
async function addNode() { async function addNode() {
if (!isAddDisabled.value && selectedGroupId.value !== null) { if (!isAddDisabled.value && selectedGroupId.value !== null) {
isAdding.value = true isAdding.value = true
try {
await nodeRepository.insert({ await nodeRepository.insert({
created_at: null, created_at: null,
id: null, id: null,
updated_at: null, updated_at: null,
alias: nodeAlias.value, alias: nodeAlias.value,
arguments: nodeArguments.value, arguments: nodeConfig.value || {},
group_id: selectedGroupId.value group_id: selectedGroupId.value
}) })
notify(t('addNode.nodeAddedSuccess')) notify(t('addNode.nodeAddedSuccess'))
// Reset form after successful submission
nodeAlias.value = ''
nodeConfig.value = null
} catch (error) {
console.error('Error adding node:', error)
notify(t('addNode.nodeAddedError'))
} finally {
isAdding.value = false isAdding.value = false
} }
} }
}
</script> </script>
<template> <template>
<v-container> <v-container>
<v-sheet class="mx-auto" width="80vw"> <v-sheet class="mx-auto" width="80vw">
<v-form fast-fail @submit.prevent> <v-form fast-fail @submit.prevent="addNode">
<v-select <v-select
v-model="selectedGroupId" v-model="selectedGroupId"
:label="$t('addNode.group')" :label="$t('addNode.group')"
@@ -62,17 +81,24 @@ async function addNode() {
:label="$t('addNode.nodeAlias')" :label="$t('addNode.nodeAlias')"
></v-text-field> ></v-text-field>
<v-textarea <v-select
v-model="nodeArguments" v-model="configurationSchema"
:label="$t('addNode.arguments')" :label="$t('addNode.nodeType')"
></v-textarea> :items="CoreTypes"
item-title="name"
return-object
variant="solo"
@update:modelValue="onConfigurationSchemaChange">
</v-select>
<node-configurator v-if="configurationSchema" :configuration-schema="configurationSchema" v-model="nodeConfig"/>
<v-btn <v-btn
class="mt-2" class="mt-2"
type="submit" type="submit"
block block
:disabled="isAddDisabled" :disabled="isAddDisabled"
:loading="isAdding" :loading="isAdding">
@click="addNode">
{{ $t('addNode.addNodeButton') }} {{ $t('addNode.addNodeButton') }}
</v-btn> </v-btn>
</v-form> </v-form>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { watch, ref, computed, toRaw} from 'vue';
import { ConfigurationSchema } from "@/utils/core/configurator/schema/schema.ts";
import SchemaField from './SchemaField.vue';
const props = defineProps<{
configurationSchema: ConfigurationSchema,
modelValue: object | null, // Allow null for initial state
}>();
const emit = defineEmits(['update:modelValue']);
// Use a ref instead of a computed property with setter to avoid proxy issues
const localConfig = ref<Record<string, any>>(props.modelValue || {});
// Watch for changes in the configurationSchema prop to reset internalConfig
watch(() => props.configurationSchema, () => {
// Reset localConfig to an empty object when schema changes
localConfig.value = {};
emit('update:modelValue', {});
}, { immediate: true });
// Watch for changes in modelValue prop to update localConfig
watch(() => props.modelValue, (newVal) => {
localConfig.value = newVal || {};
});
// Watch for changes in localConfig to emit updates
watch(localConfig, (newVal) => {
emit('update:modelValue', newVal);
}, { deep: true });
// 使用toRaw来避免响应式代理问题
const mainSchema = computed(() => {
const rawSchema = toRaw(props.configurationSchema);
return rawSchema.schema;
});
</script>
<template>
<v-card class="pa-4" variant="tonal">
<div v-for="(field, key) in mainSchema.shape" :key="key.toString()">
<SchemaField
:schema="field"
:label="key.toString()"
:model-value="localConfig[key.toString()]"
@update:modelValue="newValue => {
const newConfig = { ...localConfig.value };
newConfig[key.toString()] = newValue;
localConfig.value = newConfig;
}"
/>
</div>
</v-card>
</template>
<style scoped>
</style>

View File

@@ -32,7 +32,8 @@
"arguments": "Arguments", "arguments": "Arguments",
"addGroupButton": "Add", "addGroupButton": "Add",
"groupExists": "Group already exists.", "groupExists": "Group already exists.",
"groupAdded": "Group added." "groupAdded": "Group added.",
"nodeType": "Node Type"
}, },
"addNode": { "addNode": {
"title": "Add Node", "title": "Add Node",
@@ -40,7 +41,8 @@
"nodeAlias": "Node alias", "nodeAlias": "Node alias",
"arguments": "Arguments", "arguments": "Arguments",
"addNodeButton": "Add", "addNodeButton": "Add",
"nodeAddedSuccess": "Node added successfully" "nodeAddedSuccess": "Node added successfully",
"nodeAddedError": "Error adding node"
}, },
"common": { "common": {
"save": "Save", "save": "Save",

View File

@@ -41,7 +41,9 @@
"nodeAlias": "节点别名", "nodeAlias": "节点别名",
"arguments": "参数", "arguments": "参数",
"addNodeButton": "添加", "addNodeButton": "添加",
"nodeAddedSuccess": "节点添加成功" "nodeAddedSuccess": "节点添加成功",
"nodeAddedError": "添加节点时出错",
"nodeType": "节点类型"
}, },
"common": { "common": {
"save": "保存", "save": "保存",

View File

@@ -0,0 +1,8 @@
import {XraySchema} from "@/utils/core/configurator/schema/xray.schema.ts";
import {createConfigurationSchema} from "@/utils/core/configurator/schema/schema.ts";
import {V2flySchema} from "@/utils/core/configurator/schema/v2fly.schema.ts";
export const CoreTypes = [
createConfigurationSchema("xray", XraySchema),
createConfigurationSchema("v2fly", V2flySchema),
]

View File

@@ -0,0 +1,13 @@
import {ZodObject} from "zod";
import { markRaw } from 'vue';
// Use a simple plain object type to avoid Proxy issues
export type ConfigurationSchema = {
name: string;
schema: ZodObject<any>;
};
export function createConfigurationSchema(name: string, schema: ZodObject<any>): ConfigurationSchema {
// Use markRaw to mark the schema object to prevent Vue from converting it to reactive, thus avoiding Proxy issues
return { name, schema: markRaw(schema) };
}

View File

@@ -0,0 +1,245 @@
import {z} from "zod";
export const V2flySchema = z.object({
log: z.object({
access: z.string().optional(),
error: z.string().optional(),
loglevel: z.enum(["debug", "info", "warning", "error", "none"]).optional(),
dnsLog: z.boolean().optional(),
maskAddress: z.enum(["quarter", "half", "full"]).optional()
}).optional(),
api: z.object({
tag: z.string().optional(),
listen: z.string().optional(),
services: z.array(z.string()).optional()
}).optional(),
dns: z.object({
hosts: z.record(z.string(), z.union([
z.string(),
z.array(z.string())
])).optional(),
servers: z.array(z.union([
z.string(),
z.object({
address: z.string(),
port: z.number().optional(),
domains: z.array(z.string()).optional(),
expectedIPs: z.array(z.string()).optional(),
unexpectedIPs: z.array(z.string()).optional(),
skipFallback: z.boolean().optional(),
clientIP: z.string().optional(),
queryStrategy: z.enum(["UseIP", "UseIPv4", "UseIPv6", "UseSystem"]).optional(),
tag: z.string().optional(),
timeoutMs: z.number().optional(),
disableCache: z.boolean().optional(),
finalQuery: z.boolean().optional()
})
])).optional(),
clientIp: z.string().optional(),
queryStrategy: z.enum(["UseIP", "UseIPv4", "UseIPv6", "UseSystem"]).optional(),
disableCache: z.boolean().optional(),
disableFallback: z.boolean().optional(),
disableFallbackIfMatch: z.boolean().optional(),
useSystemHosts: z.boolean().optional(),
tag: z.string().optional()
}).optional(),
routing: z.object({
domainStrategy: z.enum(["AsIs", "IPIfNonMatch", "IPOnDemand"]).optional(),
domainMatcher: z.enum(["hybrid", "linear"]).optional(),
rules: z.array(z.object({
domainMatcher: z.enum(["hybrid", "linear"]).optional(),
type: z.literal("field").optional(),
domain: z.array(z.string()).optional(),
ip: z.array(z.string()).optional(),
port: z.union([z.number(), z.string()]).optional(),
sourcePort: z.union([z.number(), z.string()]).optional(),
network: z.enum(["tcp", "udp", "tcp,udp"]).optional(),
source: z.array(z.string()).optional(),
user: z.array(z.string()).optional(),
inboundTag: z.array(z.string()).optional(),
protocol: z.array(z.enum(["http", "tls", "bittorrent"])).optional(),
attrs: z.record(z.string(), z.string()).optional(),
outboundTag: z.string().optional(),
balancerTag: z.string().optional()
})).optional(),
balancers: z.array(z.object({
tag: z.string().optional(),
selector: z.array(z.string()).optional()
})).optional()
}).optional(),
policy: z.object({
levels: z.record(z.string(), z.object({
handshake: z.number().optional(),
connIdle: z.number().optional(),
uplinkOnly: z.number().optional(),
downlinkOnly: z.number().optional(),
statsUserUplink: z.boolean().optional(),
statsUserDownlink: z.boolean().optional(),
statsUserOnline: z.boolean().optional(),
bufferSize: z.number().optional()
})).optional(),
system: z.object({
statsInboundUplink: z.boolean().optional(),
statsInboundDownlink: z.boolean().optional(),
statsOutboundUplink: z.boolean().optional(),
statsOutboundDownlink: z.boolean().optional()
}).optional()
}).optional(),
inbounds: z.array(z.object({
listen: z.string().optional(),
port: z.union([z.number(), z.string()]).optional(),
protocol: z.enum(["dokodemo-door", "http", "shadowsocks", "socks", "vless", "vmess", "trojan", "wireguard"]).optional(),
settings: z.record(z.string(), z.any()).optional(),
streamSettings: z.record(z.string(), z.any()).optional(),
tag: z.string().optional(),
sniffing: z.object({
enabled: z.boolean().optional(),
destOverride: z.array(z.enum(["http", "tls", "quic", "fakedns"])).optional(),
metadataOnly: z.boolean().optional(),
domainsExcluded: z.array(z.string()).optional(),
routeOnly: z.boolean().optional()
}).optional(),
allocate: z.object({
strategy: z.enum(["always", "random"]).optional(),
refresh: z.number().optional(),
concurrency: z.number().optional()
}).optional()
})).optional(),
outbounds: z.array(z.object({
sendThrough: z.string().optional(),
protocol: z.string().optional(),
settings: z.record(z.string(), z.any()).optional(),
tag: z.string().optional(),
streamSettings: z.record(z.string(), z.any()).optional(),
proxySettings: z.object({
tag: z.string().optional()
}).optional(),
mux: z.object({
enabled: z.boolean().optional(),
concurrency: z.number().optional(),
xudpConcurrency: z.number().optional(),
xudpProxyUDP443: z.enum(["reject", "allow", "skip"]).optional()
}).optional(),
targetStrategy: z.enum([
"AsIs", "UseIP", "UseIPv6v4", "UseIPv6", "UseIPv4v6", "UseIPv4",
"ForceIP", "ForceIPv6v4", "ForceIPv6", "ForceIPv4v6", "ForceIPv4"
]).optional()
})).optional(),
transport: z.object({
network: z.enum(["raw", "xhttp", "kcp", "grpc", "ws", "httpupgrade"]).optional(),
security: z.enum(["none", "tls", "reality"]).optional(),
tlsSettings: z.object({
serverName: z.string().optional(),
rejectUnknownSni: z.boolean().optional(),
verifyPeerCertInNames: z.array(z.string()).optional(),
allowInsecure: z.boolean().optional(),
alpn: z.array(z.string()).optional(),
minVersion: z.string().optional(),
maxVersion: z.string().optional(),
cipherSuites: z.string().optional(),
certificates: z.array(z.object({
ocspStapling: z.number().optional(),
oneTimeLoading: z.boolean().optional(),
usage: z.enum(["encipherment", "verify", "issue"]).optional(),
buildChain: z.boolean().optional(),
certificateFile: z.string().optional(),
keyFile: z.string().optional(),
certificate: z.array(z.string()).optional()
})).optional(),
disableSystemRoot: z.boolean().optional(),
enableSessionResumption: z.boolean().optional(),
fingerprint: z.string().optional(),
pinnedPeerCertificateChainSha256: z.array(z.string()).optional(),
masterKeyLog: z.string().optional()
}).optional(),
realitySettings: z.object({
show: z.boolean().optional(),
dest: z.string().optional(),
xver: z.number().optional(),
serverNames: z.array(z.string()).optional(),
privateKey: z.string().optional(),
minClientVer: z.string().optional(),
maxClientVer: z.string().optional(),
maxTimeDiff: z.number().optional(),
shortIds: z.array(z.string()).optional(),
fingerprint: z.string().optional(),
serverName: z.string().optional(),
publicKey: z.string().optional(),
shortId: z.string().optional(),
spiderX: z.string().optional()
}).optional(),
rawSettings: z.object({}).optional(),
xhttpSettings: z.object({}).optional(),
kcpSettings: z.object({}).optional(),
grpcSettings: z.object({}).optional(),
wsSettings: z.object({}).optional(),
httpupgradeSettings: z.object({}).optional(),
sockopt: z.object({
mark: z.number().optional(),
tcpMaxSeg: z.number().optional(),
tcpFastOpen: z.boolean().optional(),
tproxy: z.enum(["off", "redirect", "tproxy"]).optional(),
domainStrategy: z.enum(["AsIs", "UseIP", "UseIPv4", "UseIPv6"]).optional(),
happyEyeballs: z.object({
tryDelayMs: z.number().optional()
}).optional(),
dialerProxy: z.string().optional(),
acceptProxyProtocol: z.boolean().optional(),
tcpKeepAliveInterval: z.number().optional(),
tcpKeepAliveIdle: z.number().optional(),
tcpUserTimeout: z.number().optional(),
tcpCongestion: z.string().optional(),
interface: z.string().optional(),
v6only: z.boolean().optional(),
tcpWindowClamp: z.number().optional(),
tcpMptcp: z.boolean().optional(),
tcpNoDelay: z.boolean().optional()
}).optional()
}).optional(),
stats: z.object({
// StatsObject currently doesn't require any parameters
// Internal statistics will be enabled as long as this object exists
}).optional(),
reverse: z.object({
bridges: z.array(z.object({
tag: z.string().optional(),
domain: z.string().optional()
})).optional(),
portals: z.array(z.object({
tag: z.string().optional(),
domain: z.string().optional()
})).optional()
}).optional(),
fakedns: z.union([
z.object({
ipPool: z.string().optional(),
poolSize: z.number().optional()
}).optional(),
z.array(z.object({
ipPool: z.string(),
poolSize: z.number().optional()
}))
]).optional(),
metrics: z.object({
tag: z.string().optional()
}).optional(),
observatory: z.object({
subjectSelector: z.array(z.string()).optional(),
probeUrl: z.string().optional(),
probeInterval: z.string().optional(),
enableConcurrency: z.boolean().optional()
}).optional(),
burstObservatory: z.object({
subjectSelector: z.array(z.string()).optional(),
pingConfig: z.object({
destination: z.string().optional(),
connectivity: z.string().optional(),
interval: z.string().optional(),
sampling: z.number().optional(),
timeout: z.string().optional()
}).optional()
}).optional()
})
export type v2flyConfig = z.infer<typeof V2flySchema>;

View File

@@ -0,0 +1,245 @@
import {z} from "zod";
export const XraySchema = z.object({
log: z.object({
access: z.string().optional(),
error: z.string().optional(),
loglevel: z.enum(["debug", "info", "warning", "error", "none"]).optional(),
dnsLog: z.boolean().optional(),
maskAddress: z.enum(["quarter", "half", "full"]).optional()
}).optional(),
api: z.object({
tag: z.string().optional(),
listen: z.string().optional(),
services: z.array(z.string()).optional()
}).optional(),
dns: z.object({
hosts: z.record(z.string(), z.union([
z.string(),
z.array(z.string())
])).optional(),
servers: z.array(z.union([
z.string(),
z.object({
address: z.string(),
port: z.number().optional(),
domains: z.array(z.string()).optional(),
expectedIPs: z.array(z.string()).optional(),
unexpectedIPs: z.array(z.string()).optional(),
skipFallback: z.boolean().optional(),
clientIP: z.string().optional(),
queryStrategy: z.enum(["UseIP", "UseIPv4", "UseIPv6", "UseSystem"]).optional(),
tag: z.string().optional(),
timeoutMs: z.number().optional(),
disableCache: z.boolean().optional(),
finalQuery: z.boolean().optional()
})
])).optional(),
clientIp: z.string().optional(),
queryStrategy: z.enum(["UseIP", "UseIPv4", "UseIPv6", "UseSystem"]).optional(),
disableCache: z.boolean().optional(),
disableFallback: z.boolean().optional(),
disableFallbackIfMatch: z.boolean().optional(),
useSystemHosts: z.boolean().optional(),
tag: z.string().optional()
}).optional(),
routing: z.object({
domainStrategy: z.enum(["AsIs", "IPIfNonMatch", "IPOnDemand"]).optional(),
domainMatcher: z.enum(["hybrid", "linear"]).optional(),
rules: z.array(z.object({
domainMatcher: z.enum(["hybrid", "linear"]).optional(),
type: z.literal("field").optional(),
domain: z.array(z.string()).optional(),
ip: z.array(z.string()).optional(),
port: z.union([z.number(), z.string()]).optional(),
sourcePort: z.union([z.number(), z.string()]).optional(),
network: z.enum(["tcp", "udp", "tcp,udp"]).optional(),
source: z.array(z.string()).optional(),
user: z.array(z.string()).optional(),
inboundTag: z.array(z.string()).optional(),
protocol: z.array(z.enum(["http", "tls", "bittorrent"])).optional(),
attrs: z.record(z.string(), z.string()).optional(),
outboundTag: z.string().optional(),
balancerTag: z.string().optional()
})).optional(),
balancers: z.array(z.object({
tag: z.string().optional(),
selector: z.array(z.string()).optional()
})).optional()
}).optional(),
policy: z.object({
levels: z.record(z.string(), z.object({
handshake: z.number().optional(),
connIdle: z.number().optional(),
uplinkOnly: z.number().optional(),
downlinkOnly: z.number().optional(),
statsUserUplink: z.boolean().optional(),
statsUserDownlink: z.boolean().optional(),
statsUserOnline: z.boolean().optional(),
bufferSize: z.number().optional()
})).optional(),
system: z.object({
statsInboundUplink: z.boolean().optional(),
statsInboundDownlink: z.boolean().optional(),
statsOutboundUplink: z.boolean().optional(),
statsOutboundDownlink: z.boolean().optional()
}).optional()
}).optional(),
inbounds: z.array(z.object({
listen: z.string().optional(),
port: z.union([z.number(), z.string()]).optional(),
protocol: z.enum(["dokodemo-door", "http", "shadowsocks", "socks", "vless", "vmess", "trojan", "wireguard"]).optional(),
settings: z.record(z.string(), z.any()).optional(),
streamSettings: z.record(z.string(), z.any()).optional(),
tag: z.string().optional(),
sniffing: z.object({
enabled: z.boolean().optional(),
destOverride: z.array(z.enum(["http", "tls", "quic", "fakedns"])).optional(),
metadataOnly: z.boolean().optional(),
domainsExcluded: z.array(z.string()).optional(),
routeOnly: z.boolean().optional()
}).optional(),
allocate: z.object({
strategy: z.enum(["always", "random"]).optional(),
refresh: z.number().optional(),
concurrency: z.number().optional()
}).optional()
})).optional(),
outbounds: z.array(z.object({
sendThrough: z.string().optional(),
protocol: z.string().optional(),
settings: z.record(z.string(), z.any()).optional(),
tag: z.string().optional(),
streamSettings: z.record(z.string(), z.any()).optional(),
proxySettings: z.object({
tag: z.string().optional()
}).optional(),
mux: z.object({
enabled: z.boolean().optional(),
concurrency: z.number().optional(),
xudpConcurrency: z.number().optional(),
xudpProxyUDP443: z.enum(["reject", "allow", "skip"]).optional()
}).optional(),
targetStrategy: z.enum([
"AsIs", "UseIP", "UseIPv6v4", "UseIPv6", "UseIPv4v6", "UseIPv4",
"ForceIP", "ForceIPv6v4", "ForceIPv6", "ForceIPv4v6", "ForceIPv4"
]).optional()
})).optional(),
transport: z.object({
network: z.enum(["raw", "xhttp", "kcp", "grpc", "ws", "httpupgrade"]).optional(),
security: z.enum(["none", "tls", "reality"]).optional(),
tlsSettings: z.object({
serverName: z.string().optional(),
rejectUnknownSni: z.boolean().optional(),
verifyPeerCertInNames: z.array(z.string()).optional(),
allowInsecure: z.boolean().optional(),
alpn: z.array(z.string()).optional(),
minVersion: z.string().optional(),
maxVersion: z.string().optional(),
cipherSuites: z.string().optional(),
certificates: z.array(z.object({
ocspStapling: z.number().optional(),
oneTimeLoading: z.boolean().optional(),
usage: z.enum(["encipherment", "verify", "issue"]).optional(),
buildChain: z.boolean().optional(),
certificateFile: z.string().optional(),
keyFile: z.string().optional(),
certificate: z.array(z.string()).optional()
})).optional(),
disableSystemRoot: z.boolean().optional(),
enableSessionResumption: z.boolean().optional(),
fingerprint: z.string().optional(),
pinnedPeerCertificateChainSha256: z.array(z.string()).optional(),
masterKeyLog: z.string().optional()
}).optional(),
realitySettings: z.object({
show: z.boolean().optional(),
dest: z.string().optional(),
xver: z.number().optional(),
serverNames: z.array(z.string()).optional(),
privateKey: z.string().optional(),
minClientVer: z.string().optional(),
maxClientVer: z.string().optional(),
maxTimeDiff: z.number().optional(),
shortIds: z.array(z.string()).optional(),
fingerprint: z.string().optional(),
serverName: z.string().optional(),
publicKey: z.string().optional(),
shortId: z.string().optional(),
spiderX: z.string().optional()
}).optional(),
rawSettings: z.object({}).optional(),
xhttpSettings: z.object({}).optional(),
kcpSettings: z.object({}).optional(),
grpcSettings: z.object({}).optional(),
wsSettings: z.object({}).optional(),
httpupgradeSettings: z.object({}).optional(),
sockopt: z.object({
mark: z.number().optional(),
tcpMaxSeg: z.number().optional(),
tcpFastOpen: z.boolean().optional(),
tproxy: z.enum(["off", "redirect", "tproxy"]).optional(),
domainStrategy: z.enum(["AsIs", "UseIP", "UseIPv4", "UseIPv6"]).optional(),
happyEyeballs: z.object({
tryDelayMs: z.number().optional()
}).optional(),
dialerProxy: z.string().optional(),
acceptProxyProtocol: z.boolean().optional(),
tcpKeepAliveInterval: z.number().optional(),
tcpKeepAliveIdle: z.number().optional(),
tcpUserTimeout: z.number().optional(),
tcpCongestion: z.string().optional(),
interface: z.string().optional(),
v6only: z.boolean().optional(),
tcpWindowClamp: z.number().optional(),
tcpMptcp: z.boolean().optional(),
tcpNoDelay: z.boolean().optional()
}).optional()
}).optional(),
stats: z.object({
// StatsObject currently doesn't require any parameters
// Internal statistics will be enabled as long as this object exists
}).optional(),
reverse: z.object({
bridges: z.array(z.object({
tag: z.string().optional(),
domain: z.string().optional()
})).optional(),
portals: z.array(z.object({
tag: z.string().optional(),
domain: z.string().optional()
})).optional()
}).optional(),
fakedns: z.union([
z.object({
ipPool: z.string().optional(),
poolSize: z.number().optional()
}).optional(),
z.array(z.object({
ipPool: z.string(),
poolSize: z.number().optional()
}))
]).optional(),
metrics: z.object({
tag: z.string().optional()
}).optional(),
observatory: z.object({
subjectSelector: z.array(z.string()).optional(),
probeUrl: z.string().optional(),
probeInterval: z.string().optional(),
enableConcurrency: z.boolean().optional()
}).optional(),
burstObservatory: z.object({
subjectSelector: z.array(z.string()).optional(),
pingConfig: z.object({
destination: z.string().optional(),
connectivity: z.string().optional(),
interval: z.string().optional(),
sampling: z.number().optional(),
timeout: z.string().optional()
}).optional()
}).optional()
})
export type xrayConfig = z.infer<typeof XraySchema>;