This commit is contained in:
admin8800
2026-05-10 06:41:44 +00:00
parent d104b6e40d
commit 3eb70ee9ed
252 changed files with 42215 additions and 2 deletions
+34
View File
@@ -0,0 +1,34 @@
<template>
<v-overlay
:model-value="loading"
persistent
content-class="text-center"
class="align-center justify-center"
>
<v-progress-circular
indeterminate
size="64"
></v-progress-circular>
<br />
{{ $t('loading') }}
</v-overlay>
<Message />
<router-view />
</template>
<script lang="ts" setup>
import Message from '@/components/message.vue'
import { inject, ref, Ref } from 'vue'
const loading:Ref = inject('loading')?? ref(false)
// Change page title
document.title = "S-UI " + document.location.hostname
</script>
<style>
.v-overlay .v-list-item,
.v-field__input {
direction: ltr;
}
</style>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 763 B

+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="1.019 0.0225 45.9789 46.9775" width="45.9789" height="46.9775" xmlns="http://www.w3.org/2000/svg">
<g featurekey="symbolFeature-0" transform="matrix(0.4545450210571289, 0, 0, 0.4545450210571289, 0.7917079329490662, 1.7009549140930176)" fill="#737373">
<g xmlns="http://www.w3.org/2000/svg">
<g>
<path d="M50,99.658L0.5,70.699V29.301L50,0.341l49.5,28.959v41.398L50,99.658z M2.5,69.553L50,97.342l47.5-27.789V30.448L50,2.659 L2.5,30.448V69.553z"/>
</g>
<g>
<polygon points="51,98.376 49,98.376 49,58.822 0.995,30.738 2.005,29.011 50,57.091 97.995,29.011 99.005,30.738 51,58.822 "/>
</g>
<g>
<polyline points="28.494,14.082 76.994,42.457 71.506,45.667 23.006,17.292 "/>
<polygon points="71.507,46.246 71.254,46.098 22.754,17.724 23.259,16.861 71.507,45.087 76.003,42.457 28.241,14.514 28.746,13.65 77.983,42.457 "/>
</g>
<g>
<polyline points="71.506,45.667 71.506,57.982 71.51,57.982 76.993,54.775 76.993,42.457 "/>
<polyline points="71.006,45.667 72.006,45.667 72.006,57.113 76.493,54.487 76.493,42.457 77.493,42.457 77.493,55.062 71.646,58.482 71,58.85 "/>
</g>
</g>
</g>
<g featurekey="nameFeature-0" transform="matrix(1.6160469055175781, 0, 0, 1.6160469055175781, 3.2854819297790527, -21.369783401489258)" fill="#a6a6a6">
<path d="M10.904 40.4028 c-5.316 0 -9.2256 -3.048 -9.2256 -6.6192 c0 -1.8592 1.2372 -2.8152 2.5116 -2.8152 c1.0924 0 2.2288 0.7184 2.2288 2.1488 c0 1.4428 -1.4112 1.9972 -1.4112 2.9972 c0 1.782 2.974 3.0552 5.338 3.0552 c3.244 0 6.382 -1.5736 6.382 -5.102 c0 -6.2716 -14.403 -3.6536 -14.403 -13.229 c0 -5.0924 4.3436 -7.6012 9.9036 -7.6012 c4.7364 0 8.9656 2.6496 8.9656 6.1048 c0 1.946 -1.2372 2.9308 -2.4828 2.9308 c-1.1216 0 -2.258 -0.7476 -2.258 -2.178 c0 -1.5584 1.382 -1.8812 1.382 -2.8812 c0 -1.6952 -2.974 -2.7436 -5.3092 -2.7436 c-3.04 0 -5.9296 1.1848 -5.9296 4.6496 c0 5.866 15.193 3.7708 15.193 13.345 c0 4.0208 -3.8304 7.938 -10.886 7.938 z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+73
View File
@@ -0,0 +1,73 @@
<template>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('out.addr')"
hide-details
required
v-model="addr.server">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('out.port')"
hide-details
type="number"
required
v-model.number="addr.server_port"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionRemark">
<v-text-field
:label="$t('in.remark')"
hide-details
v-model="addr.remark">
</v-text-field>
</v-col>
</v-row>
<OutTLS :outbound="addr" v-if="optionTLS" />
<v-row>
<v-spacer></v-spacer>
<v-col cols="auto" align="end" justify="center">
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('in.mdOption') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionRemark" color="primary" :label="$t('in.remark')" hide-details></v-switch>
</v-list-item>
<v-list-item v-if="hasTls">
<v-switch v-model="optionTLS" color="primary" :label="$t('objects.tls')" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-col>
</v-row>
</template>
<script lang="ts">
import OutTLS from '@/components/tls/OutTLS.vue'
export default {
props: ['addr', 'hasTls'],
data() {
return {
menu: false
}
},
computed: {
optionTLS: {
get(): boolean { return this.$props.addr.tls != undefined },
set(v:boolean) { this.$props.addr.tls = v ? { enabled: true } : undefined }
},
optionRemark: {
get(): boolean { return this.$props.addr.remark != undefined },
set(v:boolean) { this.$props.addr.remark = v ? '' : undefined }
}
},
components: {
OutTLS
}
}
</script>
+139
View File
@@ -0,0 +1,139 @@
<template>
<v-text-field
id="expiry"
:label="$t('date.expiry')"
v-model="dateFormatted"
prepend-inner-icon="mdi-calendar"
readonly
hide-details
></v-text-field>
<DatePicker
v-model="Input"
@input="Input=$event"
:locale="locale"
element="expiry"
compact-time
type="datetime">
<template v-slot:next-month>
<v-icon icon="mdi-chevron-right" />
</template>
<template v-slot:prev-month>
<v-icon icon="mdi-chevron-left" />
</template>
<template #submit-btn="{ submit, canSubmit }">
<v-btn
:disabled="!canSubmit"
@click="submit"
>{{ $t('submit') }}</v-btn>
</template>
<template #cancel-btn="{ vm }">
<v-btn
@click="reset(vm)"
>{{ $t('reset') }}</v-btn>
</template>
<template #now-btn="{ goToday }">
<v-btn
@click="goToday"
>{{ $t('now') }}</v-btn>
</template>
</DatePicker>
</template>
<script lang="ts">
import DatePicker from 'vue3-persian-datetime-picker'
import { i18n, locale } from '@/locales'
import 'moment/locale/ru'
import 'moment/locale/vi'
import 'moment/locale/zh-cn'
import 'moment/locale/zh-tw'
export default {
props: ['expiry'],
emits: ['submit'],
data() {
return {
menu: false,
input: new Date(),
}
},
components: { DatePicker },
computed: {
locale() {
return locale
},
dateFormatted() {
if (this.expDate == 0) return i18n.global.t('unlimited')
const date = new Date(this.expDate*1000)
return date.toLocaleString(locale)
},
expDate() {
return parseInt(this.expiry?? 0)
},
Input: {
get() { return this.expDate == 0 ? new Date() : new Date(this.expDate*1000) },
set(v:string) {
this.input = new Date(v)
this.submit()
}
}
},
methods: {
updateInput(v:Date) {
this.input = v
},
setNow() {
this.input = new Date()
},
submit() {
this.$emit('submit',Math.floor(this.input.getTime()/1000))
},
reset(vm:any) {
this.$emit('submit',0)
this.input = new Date()
vm.visible = false
}
},
watch: {
menu(v) {
if (v) {
this.input = this.expiry == 0 ? new Date() : new Date(this.expDate*1000)
}
}
}
}
</script>
<style>
.vpd-addon-list,
.vpd-addon-list-item {
background-color: rgb(var(--v-theme-background)) !important;
border-color: rgb(var(--v-theme-background)) !important;
}
.vpd-content {
background-color: rgb(var(--v-theme-background)) !important;
}
.vpd-addon-list-item.vpd-selected,
.vpd-addon-list-item:hover {
background-color: rgb(var(--v-theme-primary)) !important;
}
.vpd-close-addon {
color: rgb(var(--v-theme-on-surface)) !important;
background-color: transparent;
}
.vpd-controls {
overflow-x: hidden;
}
.vpd-month-label {
width: auto;
}
.vpd-actions button:hover {
background-color: transparent;
}
.vpd-wrapper[data-type=datetime].vpd-compact-time .vpd-time {
border-top: 0;
}
.vpd-time .vpd-time-h .vpd-counter-item,
.vpd-time .vpd-time-m .vpd-counter-item {
vertical-align: top;
}
</style>
+238
View File
@@ -0,0 +1,238 @@
<template>
<v-card :subtitle="$t('objects.dial')" style="background-color: inherit;">
<v-row>
<v-col cols="12" sm="6" md="4" v-if="optionDetour">
<v-select
hide-details
:label="$t('dial.detourText')"
:items="outTags"
v-model="dial.detour">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionBind">
<v-text-field
:label="$t('dial.bindIf')"
hide-details
v-model="dial.bind_interface"></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="optionIPV4">
<v-text-field
:label="$t('dial.bindIp4')"
hide-details
v-model="dial.inet4_bind_address"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionIPV6">
<v-text-field
:label="$t('dial.bindIp6')"
hide-details
v-model="dial.inet6_bind_address"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionBindNoPort">
<v-switch v-model="dial.bind_address_no_port" color="primary" :label="$t('dial.bindNoPort')" hide-details></v-switch>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="optionRM">
<v-text-field
label="Linux Routing Mark"
hide-details
type="number"
min="0"
v-model.number="routingMark"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionRA">
<v-switch v-model="dial.reuse_addr" color="primary" :label="$t('dial.reuseAddr')" hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="optionTCP">
<v-col cols="12" sm="6" md="4">
<v-switch v-model="dial.tcp_fast_open" color="primary" label="TCP Fast Open" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="dial.tcp_multi_path" color="primary" label="TCP Multi Path" hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="optionTcpKeepAlive">
<v-col cols="12" sm="6" md="4">
<v-switch v-model="dial.disable_tcp_keep_alive" color="primary" :label="$t('dial.disableTcpKeepAlive')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="dial.tcp_keep_alive" :label="$t('dial.tcpKeepAlive')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="dial.tcp_keep_alive_interval" :label="$t('dial.tcpKeepAliveInterval')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="optionUDP">
<v-switch v-model="dial.udp_fragment" color="primary" label="UDP Fragment" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionCT">
<v-text-field
:label="$t('dial.connTimeout')"
hide-details
type="number"
min="1"
:suffix="$t('date.s')"
v-model.number="connectTimeout"></v-text-field>
</v-col>
</v-row>
<v-row v-if="optionDR">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('dial.domainResolver')"
:items="dnsTags"
v-model="dial.domain_resolver">
</v-select>
</v-col>
</v-row>
<v-card-actions class="pt-0">
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('dial.options') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item v-if="mode != 'client'">
<v-switch v-model="optionDetour" color="primary" :label="$t('listen.detour')" hide-details></v-switch>
</v-list-item>
<v-list-item v-if="mode != 'client'">
<v-switch v-model="optionBind" color="primary" :label="$t('dial.bindIf')" hide-details></v-switch>
</v-list-item>
<v-list-item v-if="mode != 'client'">
<v-switch v-model="optionIPV4" color="primary" :label="$t('dial.bindIp4')" hide-details></v-switch>
</v-list-item>
<v-list-item v-if="mode != 'client'">
<v-switch v-model="optionIPV6" color="primary" :label="$t('dial.bindIp6')" hide-details></v-switch>
</v-list-item>
<v-list-item v-if="mode != 'client'">
<v-switch v-model="optionBindNoPort" color="primary" :label="$t('dial.bindNoPort')" hide-details></v-switch>
</v-list-item>
<v-list-item v-if="mode != 'client'">
<v-switch v-model="optionRM" color="primary" label="Routing Mark" hide-details></v-switch>
</v-list-item>
<v-list-item v-if="mode != 'client'">
<v-switch v-model="optionRA" color="primary" :label="$t('dial.reuseAddr')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionTCP" color="primary" :label="$t('listen.tcpOptions')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionUDP" color="primary" :label="$t('listen.udpOptions')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionCT" color="primary" :label="$t('dial.connTimeout')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionTcpKeepAlive" color="primary" :label="$t('dial.tcpKeepAlive')" hide-details></v-switch>
</v-list-item>
<v-list-item v-if="mode != 'client'">
<v-switch v-model="optionDR" color="primary" :label="$t('dial.domainResolver')" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import Data from '@/store/modules/data'
export default {
props: ['dial', 'mode'],
data() {
return {
menu: false
}
},
computed: {
outTags() { return [...Data().outbounds?.map((o:any) => o.tag), ...Data().endpoints?.map((e:any) => e.tag)] },
connectTimeout: {
get() { return this.$props.dial.connect_timeout ? parseInt(this.$props.dial.connect_timeout.replace('s','')) : 5 },
set(newValue:number) { this.$props.dial.connect_timeout = newValue > 0 ? newValue + 's' : '5s' }
},
routingMark: {
get() { return this.$props.dial.routing_mark?? 0 },
set(newValue:number) { this.$props.dial.routing_mark = newValue > 0 ? newValue : 0 }
},
optionDetour: {
get(): boolean { return this.$props.dial.detour != undefined },
set(v:boolean) { v ? this.$props.dial.detour = this.outTags[0]?? '' : delete this.$props.dial.detour }
},
optionBind: {
get(): boolean { return this.$props.dial.bind_interface != undefined },
set(v:boolean) { v ? this.$props.dial.bind_interface = '' : delete this.$props.dial.bind_interface }
},
optionIPV4: {
get(): boolean { return this.$props.dial.inet4_bind_address != undefined },
set(v:boolean) { v ? this.$props.dial.inet4_bind_address = '' : delete this.$props.dial.inet4_bind_address }
},
optionIPV6: {
get(): boolean { return this.$props.dial.inet6_bind_address != undefined },
set(v:boolean) { v ? this.$props.dial.inet6_bind_address = '' : delete this.$props.dial.inet6_bind_address }
},
optionBindNoPort: {
get(): boolean { return this.$props.dial.bind_address_no_port != undefined },
set(v:boolean) { v ? this.$props.dial.bind_address_no_port = true : delete this.$props.dial.bind_address_no_port }
},
optionTcpKeepAlive: {
get(): boolean {
return this.$props.dial.disable_tcp_keep_alive != undefined ||
this.$props.dial.tcp_keep_alive != undefined ||
this.$props.dial.tcp_keep_alive_interval != undefined
},
set(v:boolean) {
if (v) {
this.$props.dial.tcp_keep_alive = '5m'
this.$props.dial.tcp_keep_alive_interval = '75s'
} else {
delete this.$props.dial.disable_tcp_keep_alive
delete this.$props.dial.tcp_keep_alive
delete this.$props.dial.tcp_keep_alive_interval
}
}
},
optionRM: {
get(): boolean { return this.$props.dial.routing_mark != undefined },
set(v:boolean) { v ? this.$props.dial.routing_mark = 0 : delete this.$props.dial.routing_mark }
},
optionRA: {
get(): boolean { return this.$props.dial.reuse_addr != undefined },
set(v:boolean) { v ? this.$props.dial.reuse_addr = true : delete this.$props.dial.reuse_addr }
},
optionTCP: {
get(): boolean {
return this.$props.dial.tcp_fast_open != undefined &&
this.$props.dial.tcp_multi_path != undefined
},
set(v:boolean) {
if (v) {
this.$props.dial.tcp_fast_open = false
this.$props.dial.tcp_multi_path = false
} else {
delete this.$props.dial.tcp_fast_open
delete this.$props.dial.tcp_multi_path
}
}
},
optionUDP: {
get(): boolean { return this.$props.dial.udp_fragment != undefined },
set(v:boolean) { v ? this.$props.dial.udp_fragment = true : delete this.$props.dial.udp_fragment }
},
optionCT: {
get(): boolean { return this.$props.dial.connect_timeout != undefined },
set(v:boolean) { v ? this.$props.dial.connect_timeout = '5s' : delete this.$props.dial.connect_timeout }
},
optionDR: {
get(): boolean { return this.$props.dial.domain_resolver != undefined },
set(v:boolean) { this.$props.dial.domain_resolver = v ? this.dnsTags[0]?? '' : undefined }
},
dnsTags() {return Data().config.dns?.servers?.map((d:any) => d.tag) ?? []}
}
}
</script>
+359
View File
@@ -0,0 +1,359 @@
<template>
<v-card style="background-color: inherit;">
<v-row>
<v-col cols="12" v-if="optionInbound">
<v-combobox
v-model="rule.inbound"
:items="inTags"
:label="$t('pages.inbounds')"
multiple
chips
hide-details
></v-combobox>
</v-col>
<v-col cols="12" v-if="optionClient">
<v-combobox
v-model="rule.auth_user"
:items="clients"
:label="$t('pages.clients')"
multiple
chips
hide-details
></v-combobox>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionIPver">
<v-select
hide-details
:label="$t('rule.ipVer')"
:items="[4,6]"
v-model.number="rule.ip_version">
</v-select>
</v-col>
<v-col cols="12" sm="6" v-if="optionProtocol">
<v-combobox
v-model="rule.protocol"
:items="['http','tls', 'quic', 'stun', 'dns']"
:label="$t('protocol')"
multiple
chips
hide-details
></v-combobox>
</v-col>
</v-row>
<v-row v-if="optionDomain">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:items="domainKeys"
@update:model-value="updateDomainOption($event)"
v-model="domainOption">
</v-select>
</v-col>
<v-col cols="12" sm="6" v-if="rule.domain != undefined">
<v-text-field
:label="$t('rule.domain') + ' ' + $t('commaSeparated')"
hide-details
v-model="domain"></v-text-field>
</v-col>
<v-col cols="12" sm="6" v-if="rule.domain_suffix != undefined">
<v-text-field
:label="$t('rule.domainSufix') + ' ' + $t('commaSeparated')"
hide-details
v-model="domain_suffix"></v-text-field>
</v-col>
<v-col cols="12" sm="6" v-if="rule.domain_keyword != undefined">
<v-text-field
:label="$t('rule.domainKw') + ' ' + $t('commaSeparated')"
hide-details
v-model="domain_keyword"></v-text-field>
</v-col>
<v-col cols="12" sm="6" v-if="rule.domain_regex != undefined">
<v-text-field
:label="$t('rule.domainRgx') + ' ' + $t('commaSeparated')"
hide-details
v-model="domain_regex"></v-text-field>
</v-col>
</v-row>
<v-row v-if="optionPort">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:items="portKeys"
@update:model-value="updatePortOption($event)"
v-model="portOption">
</v-select>
</v-col>
<v-col cols="12" sm="6" v-if="rule.port != undefined">
<v-text-field
:label="$t('rule.port') + ' ' + $t('commaSeparated')"
hide-details
v-model="port"></v-text-field>
</v-col>
<v-col cols="12" sm="6" v-if="rule.port_range != undefined">
<v-text-field
:label="$t('rule.portRange') + ' ' + $t('commaSeparated')"
hide-details
v-model="port_range"></v-text-field>
</v-col>
</v-row>
<v-row v-if="optionSrcIP">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:items="srcIPKeys"
@update:model-value="updateSrcIPOption($event)"
v-model="srcIPOption">
</v-select>
</v-col>
</v-row>
<v-row v-if="optionSrcPort">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:items="srcPortKeys"
@update:model-value="updateSrcPortOption($event)"
v-model="srcPortOption">
</v-select>
</v-col>
<v-col cols="12" sm="6" v-if="rule.source_port != undefined">
<v-text-field
:label="$t('rule.srcPort') + ' ' + $t('commaSeparated')"
hide-details
v-model="source_port"></v-text-field>
</v-col>
<v-col cols="12" sm="6" v-if="rule.source_port_range != undefined">
<v-text-field
:label="$t('rule.srcPortRange') + ' ' + $t('commaSeparated')"
hide-details
v-model="source_port_range"></v-text-field>
</v-col>
</v-row>
<v-row v-if="optionRuleSet">
<v-col cols="12" sm="6">
<v-combobox
v-model="rule.rule_set"
:items="ruleSets"
:label="$t('rule.ruleset')"
multiple
chips
hide-details
></v-combobox>
</v-col>
</v-row>
<v-card-actions>
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('rule.options') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionInbound" color="primary" :label="$t('pages.inbounds')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionClient" color="primary" :label="$t('pages.clients')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionIPver" color="primary" :label="$t('rule.ipVer')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionProtocol" color="primary" :label="$t('protocol')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionDomain" color="primary" :label="$t('rule.domainRules')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionPort" color="primary" :label="$t('in.port')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionSrcIP" color="primary" :label="$t('rule.srcIpRules')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionSrcPort" color="primary" :label="$t('rule.srcPortRules')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionRuleSet" color="primary" :label="$t('rule.ruleset')" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
export default {
props: ['rule', 'clients', 'inTags', 'rsTags', 'deleteable', 'ruleSets'],
data() {
return {
menu: false,
domainKeys: ['domain', 'domain_suffix', 'domain_keyword', 'domain_regex'],
portKeys: ['port', 'port_range'],
srcIPKeys: ['source_ip_cidr', 'source_ip_is_private'],
srcPortKeys: ['source_port', 'source_port_range'],
domainOption: 'domain',
portOption: 'port',
srcIPOption: 'source_ip_cidr',
srcPortOption: 'source_port',
}
},
methods: {
updateDomainOption(option:string) {
this.domainKeys.forEach(k => delete this.$props.rule[k])
this.$props.rule[option] = []
},
updatePortOption(option:string) {
this.portKeys.forEach(k => delete this.$props.rule[k])
this.$props.rule[option] = []
},
updateSrcIPOption(option:string) {
this.srcIPKeys.forEach(k => delete this.$props.rule[k])
this.$props.rule[option] = option == 'source_ip_is_private' ? false : []
},
updateSrcPortOption(option:string) {
this.srcPortKeys.forEach(k => delete this.$props.rule[k])
this.$props.rule[option] = []
},
},
computed: {
optionInbound: {
get() { return this.$props.rule.inbound != undefined },
set(v:boolean) { this.$props.rule.inbound = v ? [] : undefined }
},
optionClient: {
get() { return this.$props.rule.auth_user != undefined },
set(v:boolean) { this.$props.rule.auth_user = v ? [] : undefined }
},
optionIPver: {
get() { return this.$props.rule.ip_version != undefined },
set(v:boolean) { this.$props.rule.ip_version = v ? 4 : undefined }
},
optionProtocol: {
get() { return this.$props.rule.protocol != undefined },
set(v:boolean) { this.$props.rule.protocol = v ? ['http'] : undefined }
},
optionDomain: {
get() { return Object.keys(this.$props.rule).some(r => this.domainKeys.includes(r)) },
set(v:boolean) {
if (v) {
this.$props.rule.domain = []
} else {
this.domainKeys.forEach(k => delete this.$props.rule[k])
}
this.domainOption = 'domain'
}
},
optionPort: {
get() { return Object.keys(this.$props.rule).some(r => this.portKeys.includes(r)) },
set(v:boolean) {
if (v) {
this.$props.rule.port = []
} else {
this.portKeys.forEach(k => delete this.$props.rule[k])
}
this.portOption = 'port'
}
},
optionSrcIP: {
get() { return Object.keys(this.$props.rule).some(r => this.srcIPKeys.includes(r)) },
set(v:boolean) {
if (v) {
this.$props.rule.source_ip_cidr = []
} else {
this.srcIPKeys.forEach(k => delete this.$props.rule[k])
}
this.srcIPOption = 'source_ip_cidr'
}
},
optionSrcPort: {
get() { return Object.keys(this.$props.rule).some(r => this.srcPortKeys.includes(r)) },
set(v:boolean) {
if (v) {
this.$props.rule.source_port = []
} else {
this.srcPortKeys.forEach(k => delete this.$props.rule[k])
}
this.srcPortOption = 'source_port'
}
},
optionRuleSet: {
get() { return this.$props.rule.rule_set != undefined },
set(v:boolean) {
if (v) {
this.$props.rule.rule_set = []
} else {
delete this.$props.rule.rule_set
}
}
},
domain: {
get() { return this.$props.rule.domain?.join(',') },
set(v:string) { this.$props.rule.domain = v.length>0 ? v.split(',') : [] }
},
domain_suffix: {
get() { return this.$props.rule.domain_suffix?.join(',') },
set(v:string) { this.$props.rule.domain_suffix = v.length>0 ? v.split(',') : [] }
},
domain_keyword: {
get() { return this.$props.rule.domain_keyword?.join(',') },
set(v:string) { this.$props.rule.domain_keyword = v.length>0 ? v.split(',') : [] }
},
domain_regex: {
get() { return this.$props.rule.domain_regex?.join(',') },
set(v:string) { this.$props.rule.domain_regex = v.length>0 ? v.split(',') : [] }
},
ip_cidr: {
get() { return this.$props.rule.ip_cidr?.join(',') },
set(v:string) { this.$props.rule.ip_cidr = v.length>0 ? v.split(',') : [] }
},
port: {
get() { return this.$props.rule.port?.join(',') },
set(v:string) {
if(!v.endsWith(',')) {
this.$props.rule.port = v.length > 0 ? v.split(',').map(str => parseInt(str, 10)) : []
}
}
},
port_range: {
get() { return this.$props.rule.port_range?.join(',') },
set(v:string) { this.$props.rule.port_range = v.length>0 ? v.split(',') : [] }
},
source_ip_cidr: {
get() { return this.$props.rule.source_ip_cidr?.join(',') },
set(v:string) { this.$props.rule.source_ip_cidr = v.length>0 ? v.split(',') : [] }
},
source_port: {
get() { return this.$props.rule.source_port?.join(',') },
set(v:string) {
if(!v.endsWith(',')) {
this.$props.rule.source_port = v.length > 0 ? v.split(',').map(str => parseInt(str, 10)) : []
}
}
},
source_port_range: {
get() { return this.$props.rule.source_port_range?.join(',') },
set(v:string) { this.$props.rule.source_port_range = v.length>0 ? v.split(',') : [] }
},
},
mounted() {
const ruleKeys = Object.keys(this.$props.rule)
if (this.optionDomain) {
const enabledOption = this.domainKeys.filter(k => ruleKeys.includes(k))
this.domainOption = enabledOption.length>0 ? enabledOption[0] : 'domain'
}
if (this.optionPort) {
const enabledOption = this.portKeys.filter(k => ruleKeys.includes(k))
this.portOption = enabledOption.length>0 ? enabledOption[0] : 'port'
}
if (this.optionSrcIP) {
const enabledOption = this.srcIPKeys.filter(k => ruleKeys.includes(k))
this.srcIPOption = enabledOption.length>0 ? enabledOption[0] : 'source_ip_cidr'
}
if (this.optionSrcPort) {
const enabledOption = this.srcPortKeys.filter(k => ruleKeys.includes(k))
this.srcPortOption = enabledOption.length>0 ? enabledOption[0] : 'source_port'
}
}
}
</script>
+133
View File
@@ -0,0 +1,133 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
{{ title }}
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px; overflow-y: scroll;">
<div class="code-editor">
<div class="line-numbers">
<span v-for="n in lineCount" :key="n">{{ n }}</span>
</div>
<v-textarea
ref="textareaRef"
v-model="content"
@scroll="syncScroll"
hide-details
variant="outlined"
bg-color="background"
:style="{ 'font-family': 'monospace' }"
no-resize
auto-grow
></v-textarea>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="primary"
variant="tonal"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { useTheme } from 'vuetify'
export default {
props: ['visible', 'data', 'title'],
emits: ['close', 'save'],
data() {
return {
content: this.$props.data,
theme: useTheme()
}
},
computed: {
lineCount() {
return this.content?.split('\n').length
}
},
methods: {
syncScroll() {
const textarea = document.querySelector('textarea')
const lineNumbers = textarea?.parentElement?.parentElement?.querySelector('.line-numbers')
if (lineNumbers && textarea) {
lineNumbers.scrollTop = textarea.scrollTop
}
},
closeModal() {
this.$emit('close')
},
saveChanges() {
this.$emit('save', this.content)
}
},
watch: {
visible(v) {
if (v) {
this.content = this.$props.data
}
}
}
}
</script>
<style scoped>
.code-editor {
direction: ltr !important;
display: flex;
border: 1px solid v-bind('theme.current.colors["outline"]');
border-radius: 4px;
overflow: hidden;
font-size: 14px; /* Consistent font size */
}
.line-numbers {
width: 40px;
background: v-bind('theme.current.colors["surface"]');
text-align: right;
padding: 12px 8px 12px 4px; /* Match textarea padding */
line-height: 1.5; /* Match textarea line height */
overflow-y: hidden; /* Prevent independent scrolling */
user-select: none;
display: flex;
flex-direction: column;
}
.line-numbers span {
display: block;
line-height: 1.5; /* Match textarea line height */
height: 1.5em; /* Ensure consistent height per line */
font-family: monospace; /* Match textarea font */
}
/* Override Vuetify textarea styles for alignment */
:deep(.v-textarea .v-field__input) {
padding: 12px 8px !important; /* Match line-numbers padding */
line-height: 1.5 !important; /* Match line-numbers line height */
font-family: monospace !important;
white-space: pre;
mask-image: inherit;
font-size: 14px !important; /* Match font size */
}
/* Ensure textarea and line numbers align */
:deep(.v-textarea textarea) {
margin-top: 0 !important; /* Remove any default margin */
padding-top: 0 !important; /* Remove any default padding */
}
</style>
+76
View File
@@ -0,0 +1,76 @@
<template>
<v-dialog v-model="dialog" max-width="620">
<v-card>
<v-card-title>{{ label }}</v-card-title>
<v-divider />
<v-card-text>
<v-row>
<v-col>{{ $t('rule.etaHint') }}</v-col>
</v-row>
<v-row>
<v-col>
<v-textarea
v-model="localText"
:label="label"
variant="outlined"
rows="16"
:counter="$t('count')"
persistent-counter
:counter-value="(v: string) => v.split('\n').filter((l: string) => l.trim().length > 0).length"
spellcheck="false"
></v-textarea>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-btn @click="resetChanges" color="error" variant="plain">{{ $t('reset') }}</v-btn>
<v-spacer />
<v-btn @click="closeModal" color="primary" variant="outlined">{{ $t('actions.close') }}</v-btn>
<v-btn @click="saveChanges" color="primary" variant="tonal">{{ $t('actions.save') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
export default {
props: ['visible', 'label', 'content'],
emits: ['update', 'close'],
data() {
return {
dialog: false,
localText: '',
}
},
watch: {
visible(v) {
if (v) {
this.localText = this.content
}
},
},
computed: {
},
methods: {
saveChanges() {
const unique = [
...new Set(
this.localText
.split('\n')
.map((l: string) => l.trim())
.filter((l: string) => l.length > 0)
),
]
this.$emit('update', unique)
this.dialog = false
},
resetChanges() {
this.localText = this.content
},
closeModal() {
this.$emit('close')
},
},
}
</script>
+104
View File
@@ -0,0 +1,104 @@
<template>
<v-card>
<v-card-text>
<v-card-subtitle>
{{ $t('objects.headers') }}
<v-chip color="primary" density="compact" variant="elevated" @click="add_header">
<v-icon icon="mdi-plus" />
</v-chip>
</v-card-subtitle>
<v-row v-for="(header, index) in hdrs">
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('objects.key')"
hide-details
@input="update_key(index,$event.target.value)"
v-model="header.name">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('objects.value')"
hide-details
@input="update_value(index,$event.target.value)"
v-model="header.value">
<template v-slot:append>
<v-icon @click="del_header(index)" color="error" icon="mdi-delete" />
</template>
</v-text-field>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script lang="ts">
type Header = {
name: string
value: string
}
export default {
props: ['data'],
data() {
return {}
},
methods: {
add_header() {
this.hdrs = [...this.hdrs, {name: "Host", value: ""}]
},
del_header(i:number) {
let h = this.hdrs
h.splice(i,1)
this.hdrs = h
},
update_key(i:number,k:string) {
let h = this.hdrs
h[i].name = k
this.hdrs = h
},
update_value(i:number,v:string) {
let h = this.hdrs
h[i].value = v
this.hdrs = h
},
},
computed: {
hdrs: {
get() :Header[] {
let headers: Header[] = []
const h = this.$props.data.headers
if (h) {
Object.keys(h).forEach(key => {
if (Array.isArray(h[key])){
h[key].forEach((v:string) => headers.push({ name: key, value: v }))
} else {
headers.push({ name: key, value: h[key] })
}
})
}
return headers
},
set(v:Header[]) {
if (v.length>0) {
let headers:any = {}
v.forEach((h:Header) => {
if (headers[h.name]) {
if (Array.isArray(headers[h.name])) {
headers[h.name].push(h.value)
} else {
headers[h.name] = [headers[h.name], h.value]
}
} else {
headers[h.name] = h.value
}
})
this.$props.data.headers = headers
} else {
this.$props.data.headers = undefined
}
}
}
}
}
</script>
+149
View File
@@ -0,0 +1,149 @@
<template>
<v-card :subtitle="$t('objects.listen')">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('in.addr')"
hide-details
required
v-model="data.listen">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('in.port')"
hide-details
type="number"
min="1"
max="65535"
required
v-model.number="data.listen_port"></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="optionDetour">
<v-select
:label="$t('listen.detourText')"
hide-details
:items="inTags"
v-model="data.detour">
</v-select>
</v-col>
</v-row>
<v-row v-if="optionTCP">
<v-col cols="12" sm="6" md="4">
<v-switch v-model="data.tcp_fast_open" color="primary" label="TCP Fast Open" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="data.tcp_multi_path" color="primary" label="TCP Multi Path" hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="optionUDP">
<v-col cols="12" sm="6" md="4">
<v-switch v-model="data.udp_fragment" color="primary" label="UDP Fragment" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="UDP NAT expiration"
hide-details
type="number"
min="1"
:suffix="$t('date.m')"
v-model.number="udpTimeout"></v-text-field>
</v-col>
</v-row>
<v-row v-if="optionTcpKeepAlive">
<v-col cols="12" sm="6" md="4">
<v-switch v-model="data.disable_tcp_keep_alive" color="primary" :label="$t('listen.disableTcpKeepAlive')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="data.tcp_keep_alive" :label="$t('listen.tcpKeepAlive')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="data.tcp_keep_alive_interval" :label="$t('listen.tcpKeepAliveInterval')" hide-details></v-text-field>
</v-col>
</v-row>
<v-card-actions class="pt-0">
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('listen.options') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionDetour" color="primary" :label="$t('listen.detour')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionTCP" color="primary" :label="$t('listen.tcpOptions')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionUDP" color="primary" :label="$t('listen.udpOptions')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionTcpKeepAlive" color="primary" :label="$t('listen.tcpKeepAlive')" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
export default {
props: ['data', 'inTags'],
data() {
return {
menu: false
}
},
computed: {
udpTimeout: {
get() { return this.$props.data.udp_timeout ? parseInt(this.$props.data.udp_timeout.replace('m','')) : 5 },
set(newValue:number) { this.$props.data.udp_timeout = newValue > 0 ? newValue + 'm' : '5m' }
},
optionTCP: {
get(): boolean {
return this.$props.data.tcp_fast_open != undefined &&
this.$props.data.tcp_multi_path != undefined
},
set(v:boolean) {
this.$props.data.tcp_fast_open = v ? false : undefined
this.$props.data.tcp_multi_path = v ? false : undefined
}
},
optionUDP: {
get(): boolean {
return this.$props.data.udp_fragment != undefined &&
this.$props.data.udp_timeout != undefined
},
set(v:boolean) {
this.$props.data.udp_fragment = v ? false : undefined
this.$props.data.udp_timeout = v ? '5m' : undefined
}
},
optionDetour: {
get(): boolean { return this.$props.data.detour != undefined },
set(v:boolean) { this.$props.data.detour = v ? this.inTags[0]?? '' : undefined }
},
optionTcpKeepAlive: {
get(): boolean {
return this.$props.data.disable_tcp_keep_alive != undefined ||
this.$props.data.tcp_keep_alive != undefined ||
this.$props.data.tcp_keep_alive_interval != undefined
},
set(v:boolean) {
if (v) {
this.$props.data.tcp_keep_alive = '5m'
this.$props.data.tcp_keep_alive_interval = '75s'
} else {
delete this.$props.data.disable_tcp_keep_alive
delete this.$props.data.tcp_keep_alive
delete this.$props.data.tcp_keep_alive_interval
}
}
}
}
}
</script>
+295
View File
@@ -0,0 +1,295 @@
<template>
<LogVue v-model="logModal.visible" :control="logModal" :visible="logModal.visible" />
<Backup v-model="backupModal.visible" :control="backupModal" :visible="backupModal.visible" />
<UsageStats v-model:visible="usageStatsModal.visible" />
<v-container class="fill-height" :loading="loading">
<v-responsive :class="reloadItems.length>0 ? 'fill-height text-center' : 'align-center'" >
<v-row class="d-flex align-center justify-center">
<v-col cols="auto">
<v-img src="@/assets/logo.svg" :width="reloadItems.length>0 ? 100 : 200"></v-img>
</v-col>
</v-row>
<v-row class="d-flex align-center justify-center">
<v-col cols="auto">
<v-dialog v-model="menu" :close-on-content-click="false" transition="scale-transition" max-width="800">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal" elevation="3">{{ $t('main.tiles') }} <v-icon icon="mdi-star-plus" /></v-btn>
</template>
<v-card rounded="xl">
<v-card-title>
<v-row>
<v-col>
{{ $t('main.tiles') }}
</v-col>
<v-spacer></v-spacer>
<v-col cols="auto"><v-icon icon="mdi-close" @click="menu = false"></v-icon></v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-row v-for="items in menuItems" density="compact">
<v-col cols="12">
<v-card :subtitle="items.title" variant="flat">
<v-card-text>
<v-row density="compact">
<v-col cols="12" md="6" lg="3" v-for="item in items.value">
<v-switch
density="compact"
v-model="reloadItems"
:value="item.value"
color="primary"
:label="item.title"
hide-details></v-switch>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card>
</v-dialog>
<v-btn variant="tonal" hide-details
style="margin-inline-start: 10px;" elevation="3"
@click="backupModal.visible = true">{{ $t('main.backup.title') }}<v-icon icon="mdi-backup-restore" />
</v-btn>
<v-btn variant="tonal" hide-details
style="margin-inline-start: 10px;" elevation="3"
@click="logModal.visible = true">{{ $t('basic.log.title') }} <v-icon icon="mdi-list-box-outline" />
</v-btn>
<v-btn variant="tonal" hide-details
style="margin-inline-start: 10px;" elevation="3"
@click="usageStatsModal.visible = true">{{ $t('main.stats.title') }} <v-icon icon="mdi-chart-box-outline" />
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="3" v-for="i in reloadItems" :key="i">
<v-card class="rounded-lg" variant="outlined" height="210px" elevation="5">
<v-card-title>
{{ menuItems.flatMap(cat => cat.value).find(m => m.value == i)?.title }}
<template v-if="i == 'i-sys'">
<v-icon icon="mdi-update" color="primary"
@click="reloadSys()" size="small" v-tooltip:top="$t('actions.update')"
style="margin-inline-start: 10px;">
</v-icon>
</template>
<template v-if="i == 'h-net'">
<v-icon icon="mdi-information" color="primary" size="small"
v-tooltip:top="'↓' +
HumanReadable.sizeFormat(tilesData.net?.recv) + ' - ' +
HumanReadable.sizeFormat(tilesData.net?.sent) + '↑'"
style="margin-inline-start: 10px;">
</v-icon>
</template>
</v-card-title>
<v-card-text style="padding: 0 16px;" align="center" justify="center">
<Gauge :tilesData="tilesData" :type="i" v-if="i.charAt(0) == 'g'" />
<History :tilesData="tilesData" :type="i" v-if="i.charAt(0) == 'h'" />
<template v-if="i == 'i-sys'">
<v-row>
<v-col cols="3">{{ $t('main.info.host') }}</v-col>
<v-col cols="9" style="text-wrap: nowrap; overflow: hidden">{{ tilesData.sys?.hostName }}</v-col>
<v-col cols="3">{{ $t('main.info.cpu') }}</v-col>
<v-col cols="9">
<v-chip density="compact" variant="flat">
<v-tooltip activator="parent" location="top" style="direction: ltr;">
{{ tilesData.sys?.cpuType }}
</v-tooltip>
{{ tilesData.sys?.cpuCount }} {{ $t('main.info.core') }}
</v-chip>
</v-col>
<v-col cols="3">IP</v-col>
<v-col cols="9">
<v-chip density="compact" color="primary" variant="flat" v-if="tilesData.sys?.ipv4?.length>0">
<v-tooltip activator="parent" location="top" style="direction: ltr;">
<span v-html="tilesData.sys?.ipv4?.join('<br />')"></span>
</v-tooltip>
IPv4
</v-chip>
<v-chip density="compact" color="primary" variant="flat" v-if="tilesData.sys?.ipv6?.length>0">
<v-tooltip activator="parent" location="top" style="direction: ltr;">
<span v-html="tilesData.sys?.ipv6?.join('<br />')"></span>
</v-tooltip>
IPv6
</v-chip>
</v-col>
<v-col cols="3">S-UI</v-col>
<v-col cols="9">
<v-chip density="compact" color="blue">
v{{ tilesData.sys?.appVersion }}
</v-chip>
</v-col>
<v-col cols="3">{{ $t('main.info.uptime') }}</v-col>
<v-col cols="9" v-tooltip:top="$t('main.info.startupTime')
+ ': ' + new Date((tilesData.sys?.bootTime || 0) * 1000).toLocaleString(locale)">
{{ HumanReadable.formatSecond((Date.now()/1000) - tilesData.sys?.bootTime) }}
</v-col>
</v-row>
</template>
<template v-if="i == 'i-sbd'">
<v-row>
<v-col cols="4">{{ $t('main.info.running') }}</v-col>
<v-col cols="8">
<v-chip density="compact" color="success" variant="flat" v-if="tilesData.sbd?.running">{{ $t('yes') }}</v-chip>
<v-chip density="compact" color="error" variant="flat" v-else>{{ $t('no') }}</v-chip>
<v-chip density="compact" color="transparent" v-if="tilesData.sbd?.running && !loading" style="cursor: pointer;" @click="restartSingbox()">
<v-tooltip activator="parent" location="top">
{{ $t('actions.restartSb') }}
</v-tooltip>
<v-icon icon="mdi-restart" color="warning" />
</v-chip>
</v-col>
<v-col cols="4">{{ $t('main.info.memory') }}</v-col>
<v-col cols="8">
<v-chip density="compact" color="primary" variant="flat" v-if="tilesData.sbd?.stats?.Alloc">
{{ HumanReadable.sizeFormat(tilesData.sbd?.stats?.Alloc) }}
</v-chip>
</v-col>
<v-col cols="4">{{ $t('main.info.threads') }}</v-col>
<v-col cols="8">
<v-chip density="compact" color="primary" variant="flat" v-if="tilesData.sbd?.stats?.NumGoroutine">
{{ tilesData.sbd?.stats?.NumGoroutine }}
</v-chip>
</v-col>
<v-col cols="4">{{ $t('main.info.uptime') }}</v-col>
<v-col cols="8">{{ HumanReadable.formatSecond(tilesData.sbd?.stats?.Uptime) }}</v-col>
<v-col cols="4">{{ $t('online') }}</v-col>
<v-col cols="8">
<template v-if="tilesData.sbd?.running">
<v-chip density="compact" color="primary" variant="flat" v-if="Data().onlines.user">
<v-tooltip activator="parent" location="top" overflow="auto">
<span v-text="$t('pages.clients')" style="font-weight: bold;"></span><br/>
<span v-for="user in Data().onlines.user">{{ user }}<br /></span>
</v-tooltip>
{{ Data().onlines.user?.length }}
</v-chip>
<v-chip density="compact" color="success" variant="flat" v-if="Data().onlines.inbound">
<v-tooltip activator="parent" location="top" :text="$t('pages.inbounds')">
<span v-text="$t('pages.inbounds')" style="font-weight: bold;"></span><br/>
<span v-for="i in Data().onlines.inbound">{{ i }}<br /></span>
</v-tooltip>
{{ Data().onlines.inbound?.length }}
</v-chip>
<v-chip density="compact" color="info" variant="flat" v-if="Data().onlines.outbound">
<v-tooltip activator="parent" location="top" :text="$t('pages.outbounds')">
<span v-text="$t('pages.outbounds')" style="font-weight: bold;"></span><br/>
<span v-for="o in Data().onlines.outbound">{{ o }}<br /></span>
</v-tooltip>
{{ Data().onlines.outbound?.length }}
</v-chip>
</template>
</v-col>
</v-row>
</template>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-responsive>
</v-container>
</template>
<script lang="ts" setup>
import HttpUtils from '@/plugins/httputil'
import { HumanReadable } from '@/plugins/utils'
import Data from '@/store/modules/data'
import Gauge from '@/components/tiles/Gauge.vue'
import History from '@/components/tiles/History.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { i18n, locale } from '@/locales'
import LogVue from '@/layouts/modals/Logs.vue'
import Backup from '@/layouts/modals/Backup.vue'
import UsageStats from '@/layouts/modals/UsageStats.vue'
const loading = ref(false)
const menu = ref(false)
const menuItems = [
{ title: i18n.global.t('main.gauges'), value: [
{ title: i18n.global.t('main.gauge.cpu'), value: "g-cpu" },
{ title: i18n.global.t('main.gauge.mem'), value: "g-mem" },
{ title: i18n.global.t('main.gauge.dsk'), value: "g-dsk" },
{ title: i18n.global.t('main.gauge.swp'), value: "g-swp" },
]
},
{ title: i18n.global.t('main.charts'), value: [
{ title: i18n.global.t('main.chart.cpu'), value: "h-cpu" },
{ title: i18n.global.t('main.chart.mem'), value: "h-mem" },
{ title: i18n.global.t('main.chart.net'), value: "h-net" },
{ title: i18n.global.t('main.chart.pnet'), value: "hp-net" },
{ title: i18n.global.t('main.chart.dio'), value: "h-dio" },
]
},
{ title: i18n.global.t('main.infos'), value: [
{ title: i18n.global.t('main.info.sys'), value: "i-sys" },
{ title: i18n.global.t('main.info.sbd'), value: "i-sbd" },
]
},
]
const tilesData = ref(<any>{})
const reloadItems = computed({
get() { return Data().reloadItems },
set(v:string[]) {
if (Data().reloadItems.length == 0 && v.length>0) startTimer()
if (Data().reloadItems.length > 0 && v.length == 0) stopTimer()
Data().reloadItems = v
v.length>0 ? localStorage.setItem("reloadItems",v.join(',')) : localStorage.removeItem("reloadItems")
}
})
const reloadData = async () => {
const request = [...new Set(reloadItems.value.map(r => r.split('-')[1]))]
if (tilesData.value?.sys?.appVersion) request.filter(r => r != 'sys')
const data = await HttpUtils.get('api/status',{ r: request.join(',')})
if (data.success) {
tilesData.value = data.obj
}
}
const reloadSys = async () => {
const data = await HttpUtils.get('api/status',{ r: 'sys'})
if (data.success) {
tilesData.value.sys = data.obj.sys
}
}
let intervalId: ReturnType<typeof setInterval> | null = null
const startTimer = () => {
intervalId = setInterval(() => {
reloadData()
}, 2000)
}
const stopTimer = () => {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
}
onMounted(async () => {
loading.value = true
if (Data().reloadItems.length != 0) {
await reloadData()
startTimer()
}
loading.value = false
})
onBeforeUnmount(() => {
stopTimer()
})
const logModal = ref({ visible: false })
const backupModal = ref({ visible: false })
const usageStatsModal = ref({ visible: false })
const restartSingbox = async () => {
loading.value = true
await HttpUtils.post('api/restartSb',{})
loading.value = false
}
</script>
+132
View File
@@ -0,0 +1,132 @@
<template>
<v-card :subtitle="$t('objects.multiplex')">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('mux.enable')" v-model="muxEnable" hide-details></v-switch>
</v-col>
<template v-if="muxEnable">
<template v-if="direction=='out'">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:items="[ 'smux', 'yamux', 'h2mux']"
:label="$t('protocol')"
clearable
@click:clear="delete mux?.protocol"
v-model="mux.protocol">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('mux.maxConn')"
hide-details
type="number"
min=0
v-model.number="max_connections">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('mux.minStr')"
hide-details
type="number"
min=0
v-model.number="min_streams">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('mux.maxStr')"
hide-details
type="number"
:min="min_streams"
v-model.number="max_streams">
</v-text-field>
</v-col>
</template>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('mux.padding')" v-model="padding" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('mux.enableBrutal')" v-model="burtalEnable" hide-details></v-switch>
</v-col>
</template>
</v-row>
<v-row v-if="mux?.brutal?.enabled">
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('stats.upload')"
hide-details
type="number"
:suffix="$t('stats.Mbps')"
v-model.number="up_mbps">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('stats.download')"
hide-details
type="number"
:suffix="$t('stats.Mbps')"
min="0"
v-model.number="down_mbps">
</v-text-field>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import { oMultiplex } from '@/types/multiplex'
export default {
props: ['data', 'direction'],
data() {
return {}
},
computed: {
mux(): oMultiplex {
return <oMultiplex> this.$props.data.multiplex ?? null
},
muxEnable: {
get(): boolean { return this.mux ? this.mux.enabled : false },
set(newValue:boolean) { this.$props.data.multiplex = newValue ? { enabled: newValue } : undefined }
},
max_connections: {
get(): number { return this.mux?.max_connections ? this.mux.max_connections : 0 },
set(newValue:number) { this.mux.max_connections = newValue > 0 ? newValue : undefined }
},
min_streams: {
get(): number { return this.mux?.min_streams ? this.mux.min_streams : 0 },
set(newValue:number) { this.mux.min_streams = newValue > 0 ? newValue : undefined }
},
max_streams: {
get(): number { return this.mux?.max_streams ? this.mux.max_streams : 0 },
set(newValue:number) { this.mux.max_streams = newValue > 0 ? newValue : undefined }
},
padding: {
get(): boolean { return this.mux?.padding ? this.mux.padding : false },
set(newValue:boolean) { this.mux.padding = newValue ? true : undefined }
},
burtalEnable: {
get(): boolean { return this.mux?.brutal ? this.mux.brutal.enabled : false },
set(newValue:boolean) { this.mux.brutal = newValue ? { enabled: newValue, up_mbps: 100, down_mbps: 100 } : undefined }
},
down_mbps: {
get() { return this.mux?.brutal && this.mux.brutal.down_mbps ? this.mux.brutal.down_mbps : 0 },
set(newValue:any) {
if (this.mux.brutal){
this.mux.brutal.down_mbps = newValue.length != 0 ? newValue : 0
}
}
},
up_mbps: {
get() { return this.mux?.brutal && this.mux.brutal.up_mbps ? this.mux.brutal.up_mbps : 0 },
set(newValue:any) {
if (this.mux.brutal){
this.mux.brutal.up_mbps = newValue.length != 0 ? newValue : 0
}
}
},
}
}
</script>
+29
View File
@@ -0,0 +1,29 @@
<template>
<v-select
hide-details
:label="$t('network')"
:items="networks"
v-model="Network">
</v-select>
</template>
<script lang="ts">
export default {
props: ['data'],
data() {
return {
networks: [
{ title: "TCP/UDP", value: '' },
{ title: "TCP", value: 'tcp' },
{ title: "UDP", value: 'udp' },
],
}
},
computed: {
Network: {
get():string { return this.$props.data.network?? '' },
set(v:string) { this.$props.data.network = v != '' ? v : undefined }
}
}
}
</script>
+152
View File
@@ -0,0 +1,152 @@
<template>
<v-card :subtitle="$t('pages.basics')">
<v-row>
<v-col cols="12" sm="6" md="4" v-if="type == inTypes.SOCKS">
<v-select
hide-details
:items="['4','4a','5']"
:label="$t('version')"
v-model="inData.out_json.version">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="needNetwork">
<Network :data="inData.out_json" />
</v-col>
<v-col cols="12" sm="6" md="4" v-if="needUot">
<UoT :data="inData.out_json" />
</v-col>
<v-col cols="12" sm="6" md="4" v-if="type == inTypes.HTTP">
<v-text-field
:label="$t('transport.path')"
hide-details
v-model="inData.out_json.path">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="type == inTypes.VMess || type == inTypes.VLESS">
<v-select
hide-details
:label="$t('types.vless.udpEnc')"
:items="['none','packetaddr','xudp']"
v-model="packet_encoding">
</v-select>
</v-col>
<template v-if="type == inTypes.VMess">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('types.vmess.security')"
:items="vmessSecurities"
v-model="inData.out_json.security">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="inData.out_json.global_padding" color="primary" :label="$t('types.vmess.globalPadding')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="inData.out_json.authenticated_length" color="primary" :label="$t('types.vmess.authLen')" hide-details></v-switch>
</v-col>
</template>
<v-col cols="12" sm="6" md="4" v-if="type == inTypes.Hysteria">
<v-text-field
label="Recv window"
hide-details
type="number"
min="0"
v-model.number="inData.out_json.recv_window">
</v-text-field>
</v-col>
<template v-if="type == inTypes.TUIC">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
label="UDP Relay Mode"
:items="['native', 'quic']"
clearable
@click:clear="delete inData.out_json.udp_relay_mode"
v-model="inData.out_json.udp_relay_mode">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" label="UDP Over Stream" v-model="inData.out_json.udp_over_stream" hide-details></v-switch>
</v-col>
</template>
</v-row>
<v-row v-if="[inTypes.Hysteria, inTypes.Hysteria2].includes(type)">
<v-col cols="12" sm="8">
<v-text-field
:label="$t('rule.portRange') + ' ' + $t('commaSeparated')"
v-model="server_ports">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('ruleset.interval')"
type="number"
min="0"
:suffix="$t('date.s')"
v-model.number="hop_interval">
</v-text-field>
</v-col>
</v-row>
<Headers :data="inData.out_json" v-if="type == inTypes.HTTP" />
<AnyTls v-if="type == inTypes.AnyTls" :data="inData.out_json" direction="out_json" />
<Naive v-if="type == inTypes.Naive" :data="inData.out_json" direction="out_json" />
</v-card>
</template>
<script lang="ts">
import { InTypes } from '@/types/inbounds'
import Network from './Network.vue'
import UoT from './UoT.vue'
import Headers from './Headers.vue'
import AnyTls from './protocols/AnyTls.vue'
import Naive from './protocols/Naive.vue'
export default {
props: ['inData', 'type'],
data() {
return {
inTypes: InTypes,
vmessSecurities: [
"auto",
"none",
"zero",
"aes-128-gcm",
"aes-128-ctr",
"chacha20-poly1305",
],
haveNetwork: [
InTypes.SOCKS,
InTypes.Shadowsocks,
InTypes.VMess,
InTypes.Trojan,
InTypes.Hysteria,
InTypes.VLESS,
InTypes.TUIC,
InTypes.Hysteria2,
],
havUoT: [
InTypes.SOCKS,
InTypes.Shadowsocks,
],
}
},
computed: {
needNetwork():boolean { return this.haveNetwork.includes(this.$props.type) },
needUot():boolean { return this.havUoT.includes(this.$props.type) },
packet_encoding: {
get() { return this.$props.inData.out_json.packet_encoding != undefined ? this.$props.inData.out_json.packet_encoding : 'none' },
set(v:string) { this.$props.inData.out_json.packet_encoding = v != "none" ? v : undefined }
},
server_ports: {
get() { return this.$props.inData.out_json.server_ports?.join(',')?? [] },
set(v:string) { this.$props.inData.out_json.server_ports = v.length > 0 ? v.split(',') : undefined }
},
hop_interval: {
get() { return this.$props.inData.out_json.hop_interval? parseInt(this.$props.inData.out_json.hop_interval.replace('s','')) : 0 },
set(v:number) { this.$props.inData.out_json.hop_interval = v>0 ? v + 's' : undefined }
},
},
components: { Network, UoT, Headers, AnyTls, Naive }
}
</script>
+568
View File
@@ -0,0 +1,568 @@
<template>
<ExpTextarea
v-model="expTextarea.visible"
:visible="expTextarea.visible"
:label="expTextarea.title"
:content="expTextarea.content"
@update="saveExpTextarea"
@close="closeExpTextarea"
/>
<v-card style="background-color: inherit;">
<v-row>
<v-col cols="12" v-if="optionInbound">
<v-combobox
v-model="rule.inbound"
:items="inTags"
:label="$t('pages.inbounds')"
multiple
chips
hide-details
></v-combobox>
</v-col>
<v-col cols="12" v-if="optionClient">
<v-combobox
v-model="rule.auth_user"
:items="clients"
:label="$t('pages.clients')"
multiple
chips
hide-details
></v-combobox>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionIPver">
<v-select
hide-details
:label="$t('rule.ipVer')"
:items="[4,6]"
v-model.number="rule.ip_version">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionNetwork">
<v-select
hide-details
multiple
chips
:label="$t('network')"
:items="['tcp','udp','icmp']"
v-model="rule.network">
</v-select>
</v-col>
<v-col cols="12" sm="6" v-if="optionProtocol">
<v-select
v-model="rule.protocol"
:items="protocols"
:label="$t('protocol')"
multiple
chips
hide-details
></v-select>
</v-col>
</v-row>
<v-row v-if="optionDomain">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:items="domainKeys"
@update:model-value="updateDomainOption($event)"
v-model="domainOption">
</v-select>
</v-col>
<v-col cols="12" sm="6" v-if="rule.domain != undefined">
<v-textarea :label="$t('rule.domain')"
hide-details
v-model="domain"
rows="5"
no-resize
density="compact"
append-icon="mdi-arrow-expand"
@click:append="openExpTextarea($t('rule.domain'), 'domain')"
/>
</v-col>
<v-col cols="12" sm="6" v-if="rule.domain_suffix != undefined">
<v-textarea :label="$t('rule.domainSufix')"
hide-details
v-model="domain_suffix"
rows="5"
no-resize
density="compact"
append-icon="mdi-arrow-expand"
@click:append="openExpTextarea($t('rule.domainSufix'), 'domain_suffix')"
/>
</v-col>
<v-col cols="12" sm="6" v-if="rule.domain_keyword != undefined">
<v-textarea :label="$t('rule.domainKw')"
hide-details
v-model="domain_keyword"
rows="5"
no-resize
density="compact"
append-icon="mdi-arrow-expand"
@click:append="openExpTextarea($t('rule.domainKw'), 'domain_keyword')"
/>
</v-col>
<v-col cols="12" sm="6" v-if="rule.domain_regex != undefined">
<v-textarea :label="$t('rule.domainRgx')"
hide-details
v-model="domain_regex"
rows="5"
no-resize
density="compact"
append-icon="mdi-arrow-expand"
@click:append="openExpTextarea($t('rule.domainRgx'), 'domain_regex')"
/>
</v-col>
<v-col cols="12" sm="6" v-if="rule.ip_cidr != undefined">
<v-textarea :label="$t('rule.ip')"
hide-details
v-model="ip_cidr"
rows="5"
no-resize
density="compact"
append-icon="mdi-arrow-expand"
@click:append="openExpTextarea($t('rule.ip'), 'ip_cidr')"
/>
</v-col>
<v-col cols="12" sm="6" v-if="rule.ip_is_private != undefined">
<v-switch v-model="rule.ip_is_private" color="primary" :label="$t('rule.privateIp')" hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="optionPort">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:items="portKeys"
@update:model-value="updatePortOption($event)"
v-model="portOption">
</v-select>
</v-col>
<v-col cols="12" sm="6" v-if="rule.port != undefined">
<v-textarea :label="$t('rule.port')"
hide-details
v-model="port"
rows="5"
no-resize
density="compact"
append-icon="mdi-arrow-expand"
@click:append="openExpTextarea($t('rule.port'), 'port')"
/>
</v-col>
<v-col cols="12" sm="6" v-if="rule.port_range != undefined">
<v-textarea :label="$t('rule.portRange')"
hide-details
v-model="port_range"
rows="5"
no-resize
density="compact"
append-icon="mdi-arrow-expand"
@click:append="openExpTextarea($t('rule.portRange'), 'port_range')"
/>
</v-col>
</v-row>
<v-row v-if="optionSrcIP">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:items="srcIPKeys"
@update:model-value="updateSrcIPOption($event)"
v-model="srcIPOption">
</v-select>
</v-col>
<v-col cols="12" sm="6" v-if="rule.source_ip_cidr != undefined">
<v-textarea :label="$t('rule.srcCidr')"
hide-details
v-model="source_ip_cidr"
rows="5"
no-resize
density="compact"
append-icon="mdi-arrow-expand"
@click:append="openExpTextarea($t('rule.srcCidr'), 'source_ip_cidr')"
/>
</v-col>
<v-col cols="12" sm="6" v-if="rule.source_ip_is_private != undefined">
<v-switch v-model="rule.source_ip_is_private" color="primary" :label="$t('rule.srcPrivateIp')" hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="optionSrcPort">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:items="srcPortKeys"
@update:model-value="updateSrcPortOption($event)"
v-model="srcPortOption">
</v-select>
</v-col>
<v-col cols="12" sm="6" v-if="rule.source_port != undefined">
<v-textarea :label="$t('rule.srcPort')"
hide-details
v-model="source_port"
rows="5"
no-resize
density="compact"
append-icon="mdi-arrow-expand"
@click:append="openExpTextarea($t('rule.srcPort'), 'source_port')"
/>
</v-col>
<v-col cols="12" sm="6" v-if="rule.source_port_range != undefined">
<v-textarea :label="$t('rule.srcPortRange')"
hide-details
v-model="source_port_range"
rows="5"
no-resize
density="compact"
append-icon="mdi-arrow-expand"
@click:append="openExpTextarea($t('rule.srcPortRange'), 'source_port_range')"
/>
</v-col>
</v-row>
<v-row v-if="optionPreferredBy">
<v-col cols="12" sm="6">
<v-combobox
v-model="rule.preferred_by"
:items="outTags || inTags"
:label="$t('rule.preferredBy')"
multiple
chips
hide-details
></v-combobox>
</v-col>
</v-row>
<v-row v-if="optionInterface">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:items="interfaceKeys"
@update:model-value="updateInterfaceOption($event)"
v-model="interfaceOption">
</v-select>
</v-col>
<v-col cols="12" sm="6" v-if="rule.interface_address != undefined || rule.network_interface_address != undefined || rule.default_interface_address != undefined">
<v-textarea :label="$t('rule.interfaceAddr')"
hide-details
v-model="interface_addr"
rows="5"
no-resize
density="compact"
append-icon="mdi-arrow-expand"
@click:append="openExpTextarea($t('rule.interfaceAddr'), 'interface_address')"
/>
</v-col>
</v-row>
<v-row v-if="optionRuleSet">
<v-col cols="12" sm="6">
<v-combobox
v-model="rule.rule_set"
:items="rsTags"
:label="$t('rule.ruleset')"
multiple
chips
hide-details
></v-combobox>
</v-col>
<v-col cols="12" sm="6">
<v-switch v-model="rule.rule_set_ip_cidr_match_source" color="primary" :label="$t('rule.rulesetMatchSrc')" hide-details></v-switch>
</v-col>
</v-row>
<v-card-actions>
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('rule.options') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionInbound" color="primary" :label="$t('pages.inbounds')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionClient" color="primary" :label="$t('pages.clients')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionIPver" color="primary" :label="$t('rule.ipVer')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionNetwork" color="primary" :label="$t('network')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionProtocol" color="primary" :label="$t('protocol')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionDomain" color="primary" :label="$t('rule.domainRules')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionPort" color="primary" :label="$t('in.port')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionSrcIP" color="primary" :label="$t('rule.srcIpRules')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionSrcPort" color="primary" :label="$t('rule.srcPortRules')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionPreferredBy" color="primary" :label="$t('rule.preferredBy')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionInterface" color="primary" :label="$t('rule.interfaceAddr')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionRuleSet" color="primary" :label="$t('rule.ruleset')" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import ExpTextarea from '@/components/ExpTextarea.vue'
export default {
components: { ExpTextarea },
props: ['rule', 'clients', 'inTags', 'outTags', 'rsTags', 'deleteable'],
data() {
return {
menu: false,
domainKeys: ['domain', 'domain_suffix', 'domain_keyword', 'domain_regex', 'ip_cidr', 'ip_is_private'],
interfaceKeys: ['interface_address', 'network_interface_address', 'default_interface_address'],
portKeys: ['port', 'port_range'],
srcIPKeys: ['source_ip_cidr', 'source_ip_is_private'],
srcPortKeys: ['source_port', 'source_port_range'],
domainOption: 'domain',
interfaceOption: 'interface_address',
portOption: 'port',
srcIPOption: 'source_ip_cidr',
srcPortOption: 'source_port',
protocols: [
{ title: 'HTTP', value: 'http' },
{ title: 'TLS', value: 'tls' },
{ title: 'QUIC', value: 'quic' },
{ title: 'STUN', value: 'stun' },
{ title: 'DNS', value: 'dns' },
{ title: 'BitTorrent', value: 'bittorrent' },
{ title: 'DTLS', value: 'dtls' },
{ title: 'SSH', value: 'ssh' },
{ title: 'RDP', value: 'rdp' },
{ title: 'NTP', value: 'ntp' },
],
expTextarea: {
visible: false,
title: '',
content: '',
object: '',
}
}
},
methods: {
updateDomainOption(option:string) {
this.domainKeys.forEach(k => delete this.$props.rule[k])
this.$props.rule[option] = option == 'ip_is_private' ? false : []
},
updatePortOption(option:string) {
this.portKeys.forEach(k => delete this.$props.rule[k])
this.$props.rule[option] = []
},
updateSrcIPOption(option:string) {
this.srcIPKeys.forEach(k => delete this.$props.rule[k])
this.$props.rule[option] = option == 'source_ip_is_private' ? false : []
},
updateSrcPortOption(option:string) {
this.srcPortKeys.forEach(k => delete this.$props.rule[k])
this.$props.rule[option] = []
},
updateInterfaceOption(option:string) {
this.interfaceKeys.forEach(k => delete this.$props.rule[k])
this.$props.rule[option] = []
},
openExpTextarea(title:string, object:string) {
this.expTextarea.visible = !this.expTextarea.visible
this.expTextarea.title = title
this.expTextarea.content = this.$props.rule[object]?.join('\n') ?? ''
this.expTextarea.object = object
},
saveExpTextarea(results:string[]) {
this.$props.rule[this.expTextarea.object] = results
this.closeExpTextarea()
},
closeExpTextarea() {
this.expTextarea.visible = false
this.expTextarea.title = ''
this.expTextarea.object = ''
},
},
computed: {
optionInbound: {
get() { return this.$props.rule.inbound != undefined },
set(v:boolean) { this.$props.rule.inbound = v ? [] : undefined }
},
optionClient: {
get() { return this.$props.rule.auth_user != undefined },
set(v:boolean) { this.$props.rule.auth_user = v ? [] : undefined }
},
optionIPver: {
get() { return this.$props.rule.ip_version != undefined },
set(v:boolean) { this.$props.rule.ip_version = v ? 4 : undefined }
},
optionProtocol: {
get() { return this.$props.rule.protocol != undefined },
set(v:boolean) { this.$props.rule.protocol = v ? ['http'] : undefined }
},
optionDomain: {
get() { return Object.keys(this.$props.rule).some(r => this.domainKeys.includes(r)) },
set(v:boolean) {
if (v) {
this.$props.rule.domain = []
} else {
this.domainKeys.forEach(k => delete this.$props.rule[k])
}
this.domainOption = 'domain'
}
},
optionPort: {
get() { return Object.keys(this.$props.rule).some(r => this.portKeys.includes(r)) },
set(v:boolean) {
if (v) {
this.$props.rule.port = []
} else {
this.portKeys.forEach(k => delete this.$props.rule[k])
}
this.portOption = 'port'
}
},
optionSrcIP: {
get() { return Object.keys(this.$props.rule).some(r => this.srcIPKeys.includes(r)) },
set(v:boolean) {
if (v) {
this.$props.rule.source_ip_cidr = []
} else {
this.srcIPKeys.forEach(k => delete this.$props.rule[k])
}
this.srcIPOption = 'source_ip_cidr'
}
},
optionSrcPort: {
get() { return Object.keys(this.$props.rule).some(r => this.srcPortKeys.includes(r)) },
set(v:boolean) {
if (v) {
this.$props.rule.source_port = []
} else {
this.srcPortKeys.forEach(k => delete this.$props.rule[k])
}
this.srcPortOption = 'source_port'
}
},
optionPreferredBy: {
get() { return this.$props.rule.preferred_by != undefined },
set(v:boolean) { this.$props.rule.preferred_by = v ? [] : undefined }
},
optionInterface: {
get() { return this.interfaceKeys.some(k => this.$props.rule[k] != undefined) },
set(v:boolean) {
if (v) {
this.$props.rule.interface_address = []
} else {
this.interfaceKeys.forEach(k => delete this.$props.rule[k])
}
this.interfaceOption = 'interface_address'
}
},
optionRuleSet: {
get() { return this.$props.rule.rule_set != undefined },
set(v:boolean) {
if (v) {
this.$props.rule.rule_set = []
this.$props.rule.rule_set_ip_cidr_match_source = false
} else {
delete this.$props.rule.rule_set
delete this.$props.rule.rule_set_ip_cidr_match_source
}
}
},
optionNetwork: {
get() { return this.$props.rule.network != undefined },
set(v:boolean) { this.$props.rule.network = v ? [] : undefined }
},
domain: {
get() { return this.$props.rule.domain?.join('\n') ?? '' },
set(v:string) { this.$props.rule.domain = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
},
domain_suffix: {
get() { return this.$props.rule.domain_suffix?.join('\n') ?? '' },
set(v:string) { this.$props.rule.domain_suffix = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
},
domain_keyword: {
get() { return this.$props.rule.domain_keyword?.join('\n') ?? '' },
set(v:string) { this.$props.rule.domain_keyword = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
},
domain_regex: {
get() { return this.$props.rule.domain_regex?.join('\n') ?? '' },
set(v:string) { this.$props.rule.domain_regex = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
},
ip_cidr: {
get() { return this.$props.rule.ip_cidr?.join('\n') ?? '' },
set(v:string) { this.$props.rule.ip_cidr = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
},
port: {
get() { return this.$props.rule.port?.join('\n') ?? '' },
set(v:string) {
const lines = v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0)
if (!v.endsWith('\n')) {
this.$props.rule.port = lines.length > 0 ? lines.map((str:string) => parseInt(str, 10)).filter((n:number) => !isNaN(n)) : []
}
}
},
port_range: {
get() { return this.$props.rule.port_range?.join('\n') ?? '' },
set(v:string) { this.$props.rule.port_range = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
},
source_ip_cidr: {
get() { return this.$props.rule.source_ip_cidr?.join('\n') ?? '' },
set(v:string) { this.$props.rule.source_ip_cidr = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
},
source_port: {
get() { return this.$props.rule.source_port?.join('\n') ?? '' },
set(v:string) {
const lines = v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0)
if (!v.endsWith('\n')) {
this.$props.rule.source_port = lines.length > 0 ? lines.map((str:string) => parseInt(str, 10)).filter((n:number) => !isNaN(n)) : []
}
}
},
source_port_range: {
get() { return this.$props.rule.source_port_range?.join('\n') ?? '' },
set(v:string) { this.$props.rule.source_port_range = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
},
interface_addr: {
get() {
const k = this.interfaceKeys.find(k => this.$props.rule[k] != undefined)
return k ? this.$props.rule[k]?.join('\n') ?? '' : ''
},
set(v:string) {
const k = this.interfaceKeys.find(k => this.$props.rule[k] != undefined)
if (k) this.$props.rule[k] = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : []
}
},
},
mounted() {
const ruleKeys = Object.keys(this.$props.rule)
if (this.optionDomain) {
const enabledOption = this.domainKeys.filter(k => ruleKeys.includes(k))
this.domainOption = enabledOption.length>0 ? enabledOption[0] : 'domain'
}
if (this.optionPort) {
const enabledOption = this.portKeys.filter(k => ruleKeys.includes(k))
this.portOption = enabledOption.length>0 ? enabledOption[0] : 'port'
}
if (this.optionSrcIP) {
const enabledOption = this.srcIPKeys.filter(k => ruleKeys.includes(k))
this.srcIPOption = enabledOption.length>0 ? enabledOption[0] : 'source_ip_cidr'
}
if (this.optionSrcPort) {
const enabledOption = this.srcPortKeys.filter(k => ruleKeys.includes(k))
this.srcPortOption = enabledOption.length>0 ? enabledOption[0] : 'source_port'
}
if (this.optionInterface) {
const enabledOption = this.interfaceKeys.filter(k => ruleKeys.includes(k))
this.interfaceOption = enabledOption.length>0 ? enabledOption[0] : 'interface_address'
}
}
}
</script>
+61
View File
@@ -0,0 +1,61 @@
<template>
<v-row density="compact">
<v-col cols="12" class="v-card-subtitle" style="margin-top: -5px;">{{ label }}</v-col>
<v-col :cols="data.type == 'local' ? 12 : 4">
<v-select
hide-details
:label="$t('type')"
:items="['udp','tcp','local','tls','quic','h3']"
@update:model-value="updateType($event)"
density="compact"
:class="data.type != 'local' ? 'noGutters' : ''"
v-model="data.type">
</v-select>
</v-col>
<v-col cols="5" v-if="data.type != 'local'">
<v-text-field
v-model="data.server"
:label="$t('in.addr')"
density="compact"
class="noGutters"
hide-details>
</v-text-field>
</v-col>
<v-col cols="3" v-if="data.type != 'local'">
<v-text-field
v-model.number="data.server_port"
:label="$t('in.port')"
density="compact"
type="number"
class="noGutters"
min="1"
hide-details>
</v-text-field>
</v-col>
</v-row>
</template>
<script lang="ts">
export default {
props: ['data', 'label'],
data() {
return {}
},
methods: {
updateType(t:string) {
if (t == 'local') {
delete this.data.server
delete this.data.server_port
}
}
}
}
</script>
<style>
.noGutters .v-field__input,
.noGutters .v-field {
text-align: center !important;
padding-inline-end: 0 !important;
}
</style>
+248
View File
@@ -0,0 +1,248 @@
<template>
<Editor
v-model="enableEditor"
:data="settings.subClashExt"
:visible="enableEditor"
:title="$t('editor') + ' - ' + $t('setting.clashSub')"
@close="enableEditor = false"
@save="saveEditor"
/>
<v-card>
<v-row>
<v-col cols="12" sm="6" md="3" lg="2" v-if="optionMixed">
<v-text-field type="number" v-model.number="mixedPort" min="1" max="65535" :label="$t('setting.mixedPort')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3" lg="2" v-if="optionMixed">
<v-switch color="primary" v-model="allowLan" :label="$t('types.ts.allowLanAccess')" hide-details />
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="3" lg="2" v-if="optionExt">
<v-text-field v-model="externalController" :label="$t('basic.exp.extController')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3" lg="2" v-if="optionLog">
<v-select v-model="logLevel" :items="['debug', 'info', 'warning', 'error']" :label="$t('basic.log.title') + ' - ' + $t('basic.log.level')" hide-details></v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="3" lg="2" v-if="optionTun">
<v-switch color="primary" v-model="tun" :label="$t('setting.tun')" hide-details />
</v-col>
<v-col cols="12" sm="6" md="3" lg="2" v-if="optionDns">
<v-switch color="primary" v-model="dns" :label="$t('pages.dns')" hide-details />
</v-col>
</v-row>
<v-row v-if="optionRules">
<v-col cols="12" sm="12" md="6" lg="4">
<v-select
v-model="rules"
:items="rulesIP"
chips
closable-chips
multiple
hide-details
:label="$t('pages.rules')"
></v-select>
</v-col>
</v-row>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="openEditor" variant="outlined" hide-details>{{ $t('editor') }}</v-btn>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('setting.jsonSubOptions') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionMixed" color="primary" :label="$t('setting.mixedPort')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionTun" color="primary" :label="$t('setting.tun')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionExt" color="primary" :label="$t('basic.exp.extController')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionLog" color="primary" :label="$t('basic.log.title')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionDns" color="primary" :label="$t('pages.dns')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionRules" color="primary" :label="$t('pages.rules')" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import { push } from 'notivue'
import Editor from './Editor.vue'
import yaml from 'yaml'
import { i18n } from '@/locales'
export default {
props: ['settings'],
data() {
return {
enableEditor: false,
menu: false,
defaultConfig: {
"mixed-port": 7890,
"allow-lan": false,
"mode": "rule",
"log-level": "info",
"external-controller": "127.0.0.1:9090",
"tun": {
"enable": true,
"stack": "system",
"auto-route": true,
"auto-detect-interface": true,
"dns-hijack": ["any:53"],
},
"dns": {
"enable": true,
"ipv6": false,
"enhanced-mode": "fake-ip",
"fake-ip-range": "198.18.0.1/16",
"default-nameserver": ["8.8.8.8","1.1.1.1"],
"nameserver": [
"https://doh.pub/dns-query",
"https://1.0.0.1/dns-query"
],
"fallback": ["tcp://9.9.9.9:53"],
"fake-ip-filter": ["*.lan", "localhost", "*.local"]
},
"rules": [
"GEOIP,Private,DIRECT",
"MATCH,Proxy"
]
},
rulesIP: [
{ title: 'Private-Direct', value: 'GEOIP,Private,DIRECT' },
{ title: 'Private-Block', value: 'GEOIP,Private,REJECT' },
{ title: 'LAN-Direct', value: 'GEOIP,LAN,DIRECT' },
{ title: 'LAN-Block', value: 'GEOIP,LAN,REJECT' },
{ title: 'Ads-Direct', value: 'GEOIP,Ads,DIRECT' },
{ title: 'Ads-Block', value: 'GEOIP,Ads,REJECT' },
{ title: '🇨🇳 China-Direct', value: 'GEOIP,CN,DIRECT' },
{ title: '🇨🇳 China-Block', value: 'GEOIP,CN,REJECT' },
{ title: '🇮🇷 Iran-Direct', value: 'GEOIP,CATEGORY-IR,DIRECT' },
{ title: '🇮🇷 Iran-Block', value: 'GEOIP,CATEGORY-IR,REJECT' },
{ title: '🇻🇳 Vietnam-Direct', value: 'GEOIP,CATEGORY-VN,DIRECT' },
{ title: '🇻🇳 Vietnam-Block', value: 'GEOIP,CATEGORY-VN,REJECT' },
{ title: '🇯🇵 Japan-Direct', value: 'GEOIP,JP,DIRECT' },
{ title: '🇯🇵 Japan-Block', value: 'GEOIP,JP,REJECT' },
],
}
},
methods: {
openEditor() {
this.enableEditor = true
},
saveEditor(data:string) {
try {
const result = yaml.parse(data)
if (typeof result != 'object' || Array.isArray(result)) throw new Error()
} catch (e) {
push.error({
message: i18n.global.t('failed') + ": " + i18n.global.t('error.invalidData'),
duration: 5000,
})
return
}
this.$props.settings.subClashExt = data
this.enableEditor = false
},
updateMetaJson(data:any, key:string) {
let newMetaJson = this.metaJson
if (data==null) {
delete newMetaJson[key]
} else {
newMetaJson[key] = data
}
this.metaJson = newMetaJson
}
},
computed: {
metaJson: {
get() {
try {
return yaml.parse(this.settings.subClashExt)??{}
} catch (e) {
return {}
}
},
set(v:any) {
this.settings.subClashExt = Object.keys(v).length==0 ? "" : yaml.stringify(v)
}
},
optionMixed: {
get() { return this.metaJson['mixed-port']>0 },
set(v:boolean) {
this.updateMetaJson(v ? this.defaultConfig['mixed-port'] : null, 'mixed-port')
this.updateMetaJson(v ? this.defaultConfig['allow-lan'] : null, 'allow-lan')
}
},
optionTun: {
get() { return this.metaJson['tun']?.['enable']?? false },
set(v:boolean) { this.updateMetaJson(v ? this.defaultConfig['tun'] : null, 'tun') }
},
optionExt: {
get() { return this.metaJson['external-controller']?.length>0 },
set(v:boolean) { this.updateMetaJson(v ? this.defaultConfig['external-controller'] : null, 'external-controller') }
},
optionLog: {
get() { return this.metaJson['log-level']?.length>0 },
set(v:boolean) { this.updateMetaJson(v ? this.defaultConfig['log-level'] : null, 'log-level') }
},
optionDns: {
get() { return this.metaJson['dns']?.['enable']?? false },
set(v:boolean) { this.updateMetaJson(v ? this.defaultConfig['dns'] : null, 'dns') }
},
optionRules: {
get() { return this.metaJson['rules']?.length>0 },
set(v:boolean) {
this.updateMetaJson(v ? this.defaultConfig['rules'] : null, 'rules')
this.updateMetaJson(v ? this.defaultConfig['mode'] : null, 'mode')
}
},
mixedPort: {
get() { return this.metaJson['mixed-port'] },
set(v:number) { this.updateMetaJson(v, 'mixed-port') }
},
allowLan: {
get() { return this.metaJson['allow-lan'] },
set(v:boolean) { this.updateMetaJson(v, 'allow-lan') }
},
externalController: {
get() { return this.metaJson['external-controller'] },
set(v:string) { this.updateMetaJson(v, 'external-controller') }
},
logLevel: {
get() { return this.metaJson['log-level'] },
set(v:string) { this.updateMetaJson(v, 'log-level') }
},
dns: {
get() { return this.metaJson['dns']?.['enable'] ?? false },
set(v:boolean) { this.updateMetaJson({ ...this.metaJson['dns'], 'enable': v }, 'dns') }
},
tun: {
get() { return this.metaJson['tun']?.['enable'] ?? false },
set(v:boolean) { this.updateMetaJson({ ...this.metaJson['tun'], 'enable': v }, 'tun') }
},
rules: {
get() { return this.metaJson.rules.length > 0 ? this.metaJson.rules.filter((r:string) => r != "MATCH,Proxy") : [] },
set(v:string[]) {
let newRules = <string[]>[]
v.forEach((r:string) => { newRules.push(r) })
this.updateMetaJson([ ...newRules, "MATCH,Proxy" ], 'rules')
}
}
},
components: { Editor }
}
</script>
+514
View File
@@ -0,0 +1,514 @@
<template>
<Editor
v-model="enableEditor"
:data="settings.subJsonExt"
:visible="enableEditor"
:title="$t('editor') + ' - ' + $t('setting.jsonSub')"
@close="enableEditor = false"
@save="saveEditor"
/>
<v-card>
<v-row>
<v-col cols="12" sm="6" md="3">
<v-select
v-model="ruleToDirect"
:items="geoList"
:label="$t('setting.toDirect')"
multiple
chips
hide-details
></v-select>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-select
v-model="ruleToBlock"
:items="geoList"
:label="$t('setting.toBlock')"
multiple
chips
hide-details
></v-select>
</v-col>
</v-row>
<v-row v-if="enableLog">
<v-col cols="12" sm="6" md="3" lg="2">
<v-select
hide-details
:label="$t('basic.log.level')"
:items="levels"
v-model="subJsonExt.log.level">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="3" lg="2">
<v-switch v-model="subJsonExt.log.timestamp" color="primary" :label="$t('setting.timestamp')" hide-details />
</v-col>
</v-row>
<v-row v-if="enableDns">
<v-col cols="12" sm="6" md="3" lg="2">
<v-select
hide-details
:label="$t('dns.final')"
:items="dnsTags"
v-model="subJsonExt.dns.final">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="3" lg="2">
<SimpleDNS :data="proxyDns" :label="$t('setting.globalDns')" />
</v-col>
<v-col cols="12" sm="6" md="3" lg="2">
<SimpleDNS :data="directDns" :label="$t('setting.directDns')" />
</v-col>
</v-row>
<v-row v-if="enableDns">
<v-col cols="12" sm="6" md="3" lg="2">
<v-select
hide-details
:label="$t('basic.routing.defaultDns')"
:items="dnsTags"
clearable
@click:clear="delete subJsonExt.default_domain_resolver"
v-model="subJsonExt.default_domain_resolver">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-select
v-model="dnsToDirect"
:items="geositeList"
:label="$t('setting.toDirectDns')"
multiple
chips
hide-details
></v-select>
</v-col>
</v-row>
<template v-if="enableInb">
<v-row>
<v-col cols="12" sm="6" md="3">
<v-combobox
v-model="inbounds[0].address"
:items="defaultInb[0].address"
chips
multiple
hide-details
:label="$t('in.addr')"
></v-combobox>
</v-col>
<v-col cols="12" sm="6" md="3" lg="2">
<v-text-field
type="number"
v-model.number="inbounds[0].mtu"
hide-details
label="MTU"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="3">
<v-combobox
v-model="inbounds[0].exclude_package"
:items="['ir.mci.ecareapp','com.myirancell']"
chips
multiple
hide-details
:label="$t('setting.excludePkg')"
></v-combobox>
</v-col>
<v-col cols="12" sm="6" md="3" lg="2">
<v-switch
v-model="platformProxy"
hide-details
color="primary"
label="Platform HTTP proxy"
></v-switch>
</v-col>
</v-row>
</template>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="openEditor" variant="outlined" hide-details>{{ $t('editor') }}</v-btn>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('setting.jsonSubOptions') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="enableLog" color="primary" :label="$t('basic.log.title')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="enableDns" color="primary" label="DNS" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="enableInb" color="primary" :label="$t('objects.inbound')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="enableExp" color="primary" label="Experimental" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import Editor from './Editor.vue'
import SimpleDNS from './SimpleDNS.vue'
import { push } from 'notivue'
import { i18n } from '@/locales'
export default {
props: ['settings'],
data() {
return {
menu: false,
enableEditor: false,
subJsonExt: <any>{},
levels: ["trace", "debug", "info", "warn", "error", "fatal", "panic"],
defaultLog: {
"level": "info",
"timestamp": true
},
defaultInb: [
{
"type": "tun",
"address": [
"172.19.0.1/30",
"fdfe:dcba:9876::1/126"
],
"mtu": 9000,
"auto_route": true,
"strict_route": false,
"endpoint_independent_nat": false,
"stack": "mixed",
"exclude_package": [],
"platform": {
"http_proxy": {
"enabled": true,
"server": "127.0.0.1",
"server_port": 2080
}
}
},
{
"type": "mixed",
"listen": "127.0.0.1",
"listen_port": 2080,
"users": []
}
],
defaultExp: {
"clash_api": {
"external_controller": "127.0.0.1:9090",
"external_ui": "ui",
"secret": "",
"external_ui_download_url": "https://mirror.ghproxy.com/https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip",
"external_ui_download_detour": "direct",
"default_mode": "rule"
},
"cache_file": {
"enabled": true,
"store_fakeip": false
}
},
defaultDns: {
"servers": [
{
"type": "tcp",
"tag": "proxy-dns",
"server": "8.8.8.8",
"server_port": 53,
"detour": "proxy",
"domain_resolver": "local-dns",
},
{
"tag": "direct-dns",
"type": "local",
},
{
"tag": "local-dns",
"type": "local",
}
],
"rules": [
{
"clash_mode": "Global",
"source_ip_cidr": [
"172.19.0.0/30",
"fdfe:dcba:9876::1/126"
],
"action": "route",
"server": "proxy-dns"
},
{
"clash_mode": "Direct",
"action": "route",
"server": "direct-dns"
},
{
"source_ip_cidr": [
"172.19.0.0/30",
"fdfe:dcba:9876::1/126"
],
"action": "route",
"server": "proxy-dns"
},
],
"final": "local-dns",
"strategy": "prefer_ipv4"
},
geositeList: [
{ title: "Private", value: "geosite-private" },
{ title: "Ads", value: "geosite-ads" },
{ title: "🇮🇷 Iran", value: "geosite-ir" },
{ title: "🇨🇳 China", value: "geosite-cn" },
{ title: "🇻🇳 Vietnam", value: "geosite-vn" },
],
geoList: [
{ title: "Site-Private", value: "geoip-private" },
{ title: "IP-Private", value: "geosite-private" },
{ title: "Site-Ads", value: "geosite-ads" },
{ title: "🇮🇷 Site-Iran", value: "geosite-ir" },
{ title: "🇮🇷 IP-Iran", value: "geoip-ir" },
{ title: "🇨🇳 Site-China", value: "geosite-cn" },
{ title: "🇨🇳 IP-China", value: "geoip-cn" },
{ title: "🇻🇳 Site-Vietnam", value: "geosite-vn" },
{ title: "🇻🇳 IP-Vietnam", value: "geoip-vn" },
],
geo: [
{
tag: "geosite-ads",
type: "remote",
format: "binary",
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geosite/category-ads-all.srs",
download_detour: "direct"
},
{
tag: "geosite-private",
type: "remote",
format: "binary",
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geosite/private.srs",
download_detour: "direct"
},
{
tag: "geosite-ir",
type: "remote",
format: "binary",
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geosite/category-ir.srs",
download_detour: "direct"
},
{
tag: "geosite-cn",
type: "remote",
format: "binary",
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geosite/cn.srs",
download_detour: "direct"
},
{
tag: "geosite-vn",
type: "remote",
format: "binary",
url: "https://github.com/Thaomtam/Geosite-vn/raw/rule-set/Geosite-vn.srs",
download_detour: "direct"
},
{
tag: "geoip-private",
type: "remote",
format: "binary",
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geoip/private.srs",
download_detour: "direct"
},
{
tag: "geoip-ir",
type: "remote",
format: "binary",
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geoip/ir.srs",
download_detour: "direct"
},
{
tag: "geoip-cn",
type: "remote",
format: "binary",
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geoip/cn.srs",
download_detour: "direct"
},
{
tag: "geoip-vn",
type: "remote",
format: "binary",
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geoip/vn.srs",
download_detour: "direct"
}
],
}
},
computed: {
enableLog: {
get() :boolean { return this.subJsonExt?.log != undefined },
set(v:boolean) { v ? this.subJsonExt.log = this.defaultLog : delete this.subJsonExt.log }
},
enableDns: {
get() :boolean { return this.subJsonExt?.dns != undefined },
set(v:boolean) {
if (v) {
this.subJsonExt.dns = this.defaultDns
if (this.rules == undefined) this.subJsonExt.rules = [{ action: 'sniff' }]
this.subJsonExt.rules.unshift({ protocol: "dns", action: "hijack-dns" })
} else {
delete this.subJsonExt.dns
const rules = this.subJsonExt?.rules?.filter((r:any) => r.protocol != "dns") ?? []
if (rules.length >= 0) this.subJsonExt.rules = rules
if (this.rules.length == 0) delete this.subJsonExt.rules
}
}
},
enableInb: {
get() :boolean { return this.subJsonExt?.inbounds != undefined },
set(v:boolean) { v ? this.subJsonExt.inbounds = this.defaultInb.slice() : delete this.subJsonExt.inbounds }
},
enableExp: {
get() :boolean { return this.subJsonExt?.experimental != undefined },
set(v:boolean) { v ? this.subJsonExt.experimental = this.defaultExp : delete this.subJsonExt.experimental }
},
dns():any { return this.subJsonExt?.dns?? undefined },
proxyDns: {
get() :any { return this.dns?.servers?.findLast((d:any) => d.tag == "proxy-dns")?? {} },
set(v:any) {
let sIndex = this.dns.servers.findIndex((d:any) => d.tag == "proxy-dns")
if (sIndex === -1 || sIndex == undefined) {
this.dns.servers.push({ ...this.defaultDns.servers[0], ...v })
} else {
this.dns.servers[sIndex] = { ...this.defaultDns.servers[0], ...v }
}
}
},
directDns: {
get() :any { return this.dns?.servers?.findLast((d:any) => d.tag == "direct-dns")?? {} },
set(v:any) {
const sIndex = this.dns.servers.findIndex((d:any) => d.tag == "direct-dns")
if (sIndex === -1 || sIndex == undefined) {
this.dns.servers.push({ ...this.defaultDns.servers[1], ...v })
} else {
this.dns.servers[sIndex] = { ...this.defaultDns.servers[1], ...v }
}
},
},
dnsTags() { return this.dns?.servers?.map((d:any) => d.tag) ?? [] },
final: {
get() :string { return this.dns.final?? "" },
set(v:string) { this.dns.final = v.length>0 ? v : undefined }
},
dnsToDirect: {
get() :string[] {
const ruleIndex = this.dns?.rules?.findIndex((r:any) => r.server == "direct-dns" && Object.hasOwn(r,'rule_set'))
return ruleIndex >= 0 ? this.dns.rules[ruleIndex].rule_set : []
},
set(v:string[]) {
const ruleIndex = this.dns?.rules?.findIndex((r:any) => r.server == "direct-dns" && Object.hasOwn(r,'rule_set'))
if (v.length>0) {
if (ruleIndex >= 0){
this.dns.rules[ruleIndex].rule_set = v
} else {
this.dns.rules.push({ rule_set: v, action: "route", server: "direct-dns" })
}
} else {
if (ruleIndex != -1) this.dns.rules.splice(ruleIndex,1)
}
this.updateRuleSets()
}
},
inbounds():any[] { return this.subJsonExt?.inbounds?? undefined },
platformProxy: {
get() :boolean { return this.inbounds[0]?.platform != undefined },
set(v:boolean) { this.subJsonExt.inbounds[0].platform = v ? this.defaultInb[0].platform : undefined }
},
rules():any { return this.subJsonExt?.rules?? undefined },
ruleToDirect: {
get() :string[] {
const ruleIndex = this.rules?.findIndex((r:any) => r.outbound == "direct" && Object.hasOwn(r,'rule_set'))
return ruleIndex >= 0 ? this.rules[ruleIndex].rule_set : []
},
set(v:string[]) {
const ruleIndex = this.rules?.findIndex((r:any) => r.outbound == "direct" && Object.hasOwn(r,'rule_set'))
if (v.length>0) {
if (ruleIndex >= 0){
this.rules[ruleIndex].rule_set = v
} else {
if (this.rules == undefined) this.subJsonExt.rules = []
this.rules.push({ rule_set: v, action: "route", outbound: "direct" })
}
} else {
if (ruleIndex != -1) this.rules.splice(ruleIndex,1)
}
this.updateRuleSets()
}
},
ruleToBlock: {
get() :string[] {
const ruleIndex = this.rules?.findIndex((r:any) => r.action == "reject" && Object.hasOwn(r,'rule_set'))
return ruleIndex >= 0 ? this.rules[ruleIndex].rule_set : []
},
set(v:string[]) {
const ruleIndex = this.rules?.findIndex((r:any) => r.action == "reject" && Object.hasOwn(r,'rule_set'))
if (v.length>0) {
if (ruleIndex >= 0){
this.rules[ruleIndex].rule_set = v
} else {
if (this.rules == undefined) this.subJsonExt.rules = []
this.rules.push({ rule_set: v, action: "reject" })
}
} else {
if (ruleIndex != -1) this.rules.splice(ruleIndex,1)
}
this.updateRuleSets()
}
}
},
methods: {
loadData() {
if (this.$props.settings?.subJsonExt?.length>0){
this.subJsonExt = JSON.parse(this.$props.settings.subJsonExt)
} else {
this.subJsonExt = <any>{}
}
},
updateRuleSets(){
let tags = <string[]>[]
if (this.dns?.rules?.length>0) this.dns.rules.forEach((r:any) => { if (r.rule_set) tags.push(...r.rule_set) })
if (this.rules?.length>0) this.rules.forEach((r:any) => { if (r.rule_set) tags.push(...r.rule_set) })
if (tags.length>0){
this.subJsonExt.rule_set = this.geo.filter((g:any) => tags.includes(g.tag))
} else {
delete this.subJsonExt.rule_set
}
if (this.rules.length == 0) delete this.subJsonExt.rules
},
openEditor() {
this.enableEditor = true
},
saveEditor(data:string) {
try {
this.subJsonExt = JSON.parse(data)
} catch (e) {
push.error({
message: i18n.global.t('failed') + ": " + i18n.global.t('error.invalidData'),
duration: 5000,
})
return
}
this.enableEditor = false
}
},
mounted(){
this.loadData()
},
watch:{
subJsonExt:{
handler(v) {
this.$props.settings.subJsonExt = Object.keys(v).length>0 ? JSON.stringify(v, null, 2) : ""
},
deep: true
},
},
components: { Editor, SimpleDNS }
}
</script>
+51
View File
@@ -0,0 +1,51 @@
<template>
<v-card :subtitle="$t('objects.transport')">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('transport.enable')" v-model="tpEnable" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="tpEnable">
<v-select
hide-details
:label="$t('type')"
:items="Object.keys(trspTypes).map((key,index) => ({title: key, value: Object.values(trspTypes)[index]}))"
v-model="transportType">
</v-select>
</v-col>
</v-row>
<Http v-if="Transport.type == trspTypes.HTTP" :transport="Transport" />
<WebSocket v-if="Transport.type == trspTypes.WebSocket" :transport="Transport" />
<GRPC v-if="Transport.type == trspTypes.gRPC" :transport="Transport" />
<HttpUpgrade v-if="Transport.type == trspTypes.HTTPUpgrade" :transport="Transport" />
</v-card>
</template>
<script lang="ts">
import { TrspTypes, Transport } from '@/types/transport'
import Http from './transports/Http.vue'
import WebSocket from './transports/WebSocket.vue'
import GRPC from './transports/gRPC.vue'
import HttpUpgrade from './transports/HttpUpgrade.vue'
export default {
props: ['data'],
data() {
return {
trspTypes: TrspTypes
}
},
computed: {
Transport() {
return <Transport>this.$props.data.transport
},
tpEnable: {
get() { return Object.hasOwn(this.$props.data.transport, 'type') },
set(newValue: boolean) { this.$props.data.transport = newValue ? { type: 'http' } : {} }
},
transportType: {
get() { return this.Transport.type },
set(newValue: string) { this.$props.data.transport = { type: newValue } }
}
},
components: { Http, WebSocket, GRPC, HttpUpgrade }
}
</script>
+29
View File
@@ -0,0 +1,29 @@
<template>
<v-select
hide-details
label="UDP over TCP"
:items="versions"
v-model="udp_over_tcp">
</v-select>
</template>
<script lang="ts">
export default {
props: ['data'],
data() {
return {
versions: [
{ title: this.$t('disable'), value: 0 },
{ title: "1", value: 1 },
{ title: "2", value: 2 },
],
}
},
computed: {
udp_over_tcp: {
get():number { return this.$props.data.udp_over_tcp?.version?? 0 },
set(v:number) { this.$props.data.udp_over_tcp = v > 0 ? { enabled: true, version: v } : undefined }
}
}
}
</script>
+42
View File
@@ -0,0 +1,42 @@
<template>
<v-card :subtitle="$t('pages.clients')">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select v-model="data.model" :items="initUsersModels" @update:model-value="data.values = []" hide-details></v-select>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.model == 'group'">
<v-select v-model="data.values" multiple chips :items="groupNames" :label="$t('client.group')" hide-details></v-select>
</v-col>
<v-col cols="12" sm="8" v-if="data.model == 'client'">
<v-select v-model="data.values" multiple chips :items="clientNames" :label="$t('pages.clients')" hide-details></v-select>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import { i18n } from '@/locales'
export default {
props: ['data', 'clients'],
data() {
return {
initUsersModels: [
{ title: i18n.global.t('none'), value: 'none' },
{ title: i18n.global.t('all'), value: 'all' },
{ title: i18n.global.t('client.group'), value: 'group' },
{ title: i18n.global.t('pages.clients'), value: 'client' },
],
}
},
computed: {
clientNames() {
return this.$props.clients.map((c:any) => { return { title: c.name, value: c.id } } )
},
groupNames() {
return Array.from(new Set(this.$props.clients.map((c:any) => c.group)))
},
}
}
</script>
+113
View File
@@ -0,0 +1,113 @@
<template>
<v-row>
<v-col cols="12" sm="8">
<v-text-field
v-model="privateKey"
:label="$t('types.wg.privKey')"
append-icon="mdi-key-star"
@click:append="refreshKey"
hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="8">
<v-text-field v-model="publicKey" :label="$t('types.wg.pubKey')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="8">
<v-text-field v-model="data.pre_shared_key" :label="$t('types.wg.psk')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('out.addr')"
hide-details
v-model="address">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('out.port')"
type="number"
min="0"
hide-details
v-model.number="port">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="KeepAlive"
type="number"
min="0"
:suffix="$t('date.s')"
hide-details
v-model.number="keepAlive">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6">
<v-text-field v-model="allowed_ips" :label="$t('types.wg.allowedIp') + ' ' + $t('commaSeparated')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field v-model="reserved" :label="'Reserved ' + $t('commaSeparated')" hide-details></v-text-field>
</v-col>
</v-row>
</template>
<script lang="ts">
export default {
props: ['data', 'ext'],
emits: ['refreshPeerKey'],
data() {
return {}
},
methods: {
refreshKey() {
this.$emit('refreshPeerKey')
}
},
computed: {
allowed_ips: {
get() { return this.$props.data.allowed_ips?.join(',') },
set(v:string) { this.$props.data.allowed_ips = v.length > 0 ? v.split(',') : undefined }
},
reserved: {
get() { return this.$props.data.reserved?.join(',') },
set(v:string) {
if(!v.endsWith(',')) {
this.$props.data.reserved = v.length > 0 ? v.split(',').map(str => parseInt(str, 10)) : undefined
}
}
},
address: {
get() { return this.$props.data.address },
set(v:string) { this.$props.data.address = v.length > 0 ? v : undefined }
},
port: {
get() { return this.$props.data.port },
set(v:number) { this.$props.data.port = v > 0 ? v : undefined }
},
keepAlive: {
get() { return this.$props.data.persistent_keepalive_interval?? 0 },
set(v:number) { this.$props.data.persistent_keepalive_interval = v > 0 ? v : undefined }
},
privateKey: {
get() {
const indexKeys = this.$props.ext?.keys.findIndex((key: any) => key.public_key == this.$props.data.public_key)?? -1
return indexKeys > -1 ? this.$props.ext.keys[indexKeys].private_key : ''
},
set(v:string) {
const indexKeys = this.$props.ext?.keys.findIndex((key: any) => key.public_key == this.$props.data.public_key)?? -1
this.$props.ext.keys[indexKeys].private_key = v
}
},
publicKey: {
get() { return this.$props.data.public_key },
set(v:string) {
const indexKeys = this.$props.ext?.keys.findIndex((key: any) => key.public_key == this.$props.data.public_key)?? -1
this.$props.ext.keys[indexKeys].public_key = v
this.$props.data.public_key = v
}
}
}
}
</script>
+43
View File
@@ -0,0 +1,43 @@
<template>
<Notivue v-slot="item">
<NotivueSwipe :item="item">
<Notification
:item="item"
:theme="theme"
:dir="direction"
:icons="outlinedIcons"
:hideClose="true"
@click="item.clear"
/>
</NotivueSwipe>
</Notivue>
</template>
<script lang="ts" setup>
import { Notivue, Notification, NotivueSwipe, outlinedIcons, pastelTheme, darkTheme } from 'notivue'
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import vuetify from '@/plugins/vuetify'
const Theme = useTheme()
const theme = computed(() =>{
let currenTheme = Theme.global.name.value == "light" ? pastelTheme : darkTheme
currenTheme = {
...currenTheme,
'--nv-width': 'auto',
}
return currenTheme
})
const direction = computed(() => {
return vuetify.locale.isRtl ? 'rtl' : 'ltr'
})
</script>
<style>
:root {
--nv-z: 10020;
}
</style>
@@ -0,0 +1,80 @@
<template>
<v-card>
<v-card-subtitle v-if="direction != 'out_json'">AnyTls</v-card-subtitle>
<v-row v-if="direction == 'in'">
<v-col cols="12" sm="8">
<v-textarea
label="Padding scheme"
auto-grow
hide-details
v-model="padding_scheme">
</v-textarea>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12" sm="8" v-if="direction == 'out'">
<v-text-field
:label="$t('types.pw')"
hide-details
v-model="data.password">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.anytls.idleInterval')"
type="number"
min="0"
hide-details
:suffix="$t('date.s')"
v-model.number="idleInterval">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.anytls.idleTimeout')"
type="number"
min="0"
hide-details
:suffix="$t('date.s')"
v-model.number="idleTimeout">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.anytls.minIdle')"
type="number"
min="0"
hide-details
v-model.number="minIdle">
</v-text-field>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
export default {
props: ['data', 'direction'],
data() {
return {}
},
computed: {
padding_scheme: {
get() { return this.data.padding_scheme?.length > 0 ? this.data.padding_scheme.join("\n") : '' },
set(v:string) { this.data.padding_scheme = v.length > 0 ? v.split("\n") : undefined }
},
idleInterval: {
get() { return this.data.idle_session_check_interval?.length > 0 ? parseInt(this.data.idle_session_check_interval.replace('s','')) : 30 },
set(v:number) { this.data.idle_session_check_interval = v && v >= 0 ? `${v}s` : undefined }
},
idleTimeout: {
get() { return this.data.idle_session_timeout?.length > 0 ? parseInt(this.data.idle_session_timeout.replace('s','')) : 30 },
set(v:number) { this.data.idle_session_timeout = v && v >= 0 ? `${v}s` : undefined }
},
minIdle: {
get() { return this.data.min_idle_session != undefined ? this.data.min_idle_session : 0 },
set(v:number) { this.data.min_idle_session = v>0 ? v : undefined }
}
}
}
</script>
@@ -0,0 +1,43 @@
<template>
<v-card subtitle="Direct">
<v-row>
<v-col cols="12" sm="6" md="4">
<Network :data="data" />
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.direct.overrideAddr')"
hide-details
v-model="data.override_address">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.direct.overridePort')"
type="number"
min="0"
hide-details
v-model.number="override_port">
</v-text-field>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
export default {
props: ['data'],
data() {
return {}
},
computed: {
override_port: {
get() { return this.$props.data.override_port ? this.$props.data.override_port : '' },
set(newValue: any) { this.$props.data.override_port = newValue.length == 0 || newValue == 0 ? undefined : parseInt(newValue) }
},
},
components: { Network }
}
</script>
@@ -0,0 +1,50 @@
<template>
<v-card subtitle="HTTP">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.un')"
hide-details
v-model="username">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.pw')"
hide-details
v-model="password">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.path')"
hide-details
v-model="data.path">
</v-text-field>
</v-col>
</v-row>
<Headers :data="data" />
</v-card>
</template>
<script lang="ts">
import Headers from '@/components/Headers.vue'
export default {
props: ['data'],
data() {
return {}
},
computed: {
username: {
get(): string { return this.data.username?.length > 0 ? this.data.username : '' },
set(v:string) { this.data.username = v.length > 0 ? v : undefined },
},
password: {
get(): string { return this.data.password?.length > 0 ? this.data.password : '' },
set(v:string) { this.data.password = v.length > 0 ? v : undefined },
},
},
components: { Headers }
}
</script>
@@ -0,0 +1,159 @@
<template>
<v-card subtitle="Hysteria">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('stats.upload')"
hide-details
type="number"
:suffix="$t('stats.Mbps')"
v-model.number="up_mbps">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('stats.download')"
hide-details
type="number"
:suffix="$t('stats.Mbps')"
min="0"
v-model.number="down_mbps">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.hy.obfs')"
hide-details
v-model="data.obfs">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="direction=='out'">
<v-text-field
:label="$t('types.hy.auth')"
hide-details
v-model="data.auth_str">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="direction=='out'">
<Network :data="data" />
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="data.disable_mtu_discovery" color="primary" label="Disable MTU discovery" hide-details></v-switch>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="data.recv_window_conn != undefined">
<v-text-field
label="Recv window conn"
hide-details
type="number"
min="0"
v-model.number="data.recv_window_conn">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.recv_window != undefined">
<v-text-field
label="Recv window"
hide-details
type="number"
min="0"
v-model.number="data.recv_window">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.recv_window_client != undefined">
<v-text-field
label="Recv window client"
hide-details
type="number"
min="0"
v-model.number="data.recv_window_client">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.max_conn_client != undefined">
<v-text-field
label="Max conn client"
hide-details
type="number"
min="0"
v-model.number="data.max_conn_client">
</v-text-field>
</v-col>
</v-row>
<v-card-actions>
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('types.hy.hyOptions') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionRsvConn" color="primary" label="Recv window conn" hide-details></v-switch>
</v-list-item>
<v-list-item v-if="direction=='out'">
<v-switch v-model="optionRsvWin" color="primary" label="Recv window" hide-details></v-switch>
</v-list-item>
<v-list-item v-if="direction=='in'">
<v-switch v-model="optionRsvClnt" color="primary" label="Recv window client" hide-details></v-switch>
</v-list-item>
<v-list-item v-if="direction=='in'">
<v-switch v-model="optionMaxConn" color="primary" label="Max conn client" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
export default {
props: ['direction','data'],
data() {
return {
menu: false,
}
},
computed: {
optionRsvConn: {
get(): boolean { return this.$props.data.recv_window_conn != undefined },
set(v:boolean) { this.$props.data.recv_window_conn = v ? 15728640 : undefined }
},
optionRsvWin: {
get(): boolean { return this.$props.data.recv_window != undefined },
set(v:boolean) { this.$props.data.recv_window = v ? 67108864 : undefined }
},
optionRsvClnt: {
get(): boolean { return this.$props.data.recv_window_client != undefined },
set(v:boolean) { this.$props.data.recv_window_client = v ? 67108864 : undefined }
},
optionMaxConn: {
get(): boolean { return this.$props.data.max_conn_client != undefined },
set(v:boolean) { this.$props.data.max_conn_client = v ? 1024 : undefined }
},
down_mbps: {
get() { return this.$props.data.down_mbps ? this.$props.data.down_mbps : 0 },
set(newValue:any) {
if (newValue.length != 0 ){
this.$props.data.down_mbps = newValue
this.$props.data.down = "" + newValue + " Mbps"
} else {
this.$props.data.down_mbps = 0
this.$props.data.down = "0 Mbps"
}
}
},
up_mbps: {
get() { return this.$props.data.up_mbps ? this.$props.data.up_mbps : 0 },
set(newValue:number) { this.$props.data.up_mbps = newValue > 0 ? newValue : 0 }
},
},
components: { Network }
}
</script>
@@ -0,0 +1,219 @@
<template>
<v-card subtitle="Hysteria2">
<v-row>
<v-col cols="12" sm="6" md="4" v-if="direction == 'in'">
<v-switch v-model="data.ignore_client_bandwidth" color="primary" :label="$t('types.hy.ignoreBw')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="!data.ignore_client_bandwidth">
<v-text-field
:label="$t('stats.upload')"
hide-details
type="number"
:suffix="$t('stats.Mbps')"
min="0"
v-model.number="up_mbps">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="!data.ignore_client_bandwidth">
<v-text-field
:label="$t('stats.download')"
hide-details
type="number"
:suffix="$t('stats.Mbps')"
min="0"
v-model.number="down_mbps">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="data.obfs != undefined">
<v-text-field
:label="$t('types.hy.obfs')"
hide-details
v-model="data.obfs.password">
</v-text-field>
</v-col>
</v-row>
<template v-if="direction == 'in'">
<v-card subtitle="Hysteria2 Masquerade" v-if="data.masquerade != undefined">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select v-model="masqueradeType" hide-details :label="$t('type')" :items="masqTypes"></v-select>
</v-col>
<v-col cols="12" sm="8" v-if="masqueradeType == ''">
<v-text-field
label="HTTP3 server on auth fails"
placeholder="file:///var/www | http://127.0.0.1:8080"
v-model="data.masquerade"
hide-details>
</v-text-field>
</v-col>
<v-col cols="12" sm="8" v-if="masqueradeType == 'file'">
<v-text-field
label="File server root directory"
placeholder="/var/www"
v-model="data.masquerade.directory"
hide-details>
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="masqueradeType == 'string'">
<v-text-field
label="HTTP Code"
type="number"
min="100"
max="599"
v-model.number="data.masquerade.status_code"
hide-details>
</v-text-field>
</v-col>
</v-row>
<v-row v-if="masqueradeType == 'proxy'">
<v-col cols="12" sm="6">
<v-text-field
label="Target URL"
placeholder="http://example.com:8080"
v-model="data.masquerade.url"
hide-details>
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch
label="Rewrite Host"
v-model="data.masquerade.rewrite_host"
color="primary"
hide-details>
</v-switch>
</v-col>
</v-row>
<template v-if="masqueradeType == 'string'">
<v-row>
<v-col cols="12" sm="8">
<v-text-field
label="Content"
v-model="data.masquerade.content"
hide-details>
</v-text-field>
</v-col>
</v-row>
<Headers :data="data.masquerade" />
</template>
</v-card>
</template>
<template v-else>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.pw')"
hide-details
v-model="data.password">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<Network :data="data" />
</v-col>
<v-col cols="12" sm="8" v-if="optionMPort">
<v-text-field
:label="$t('rule.portRange') + ' ' + $t('commaSeparated')"
v-model="server_ports">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionMPort">
<v-text-field
:label="$t('ruleset.interval')"
type="number"
min="0"
:suffix="$t('date.s')"
v-model.number="hop_interval">
</v-text-field>
</v-col>
</v-row>
</template>
<v-card-actions>
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('types.hy.hy2Options') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionObfs" color="primary" :label="$t('types.hy.obfs')" hide-details></v-switch>
</v-list-item>
<template v-if="direction == 'in'">
<v-list-item>
<v-switch v-model="optionMasq" color="primary" label="Masquerade" hide-details></v-switch>
</v-list-item>
</template>
<template v-else>
<v-list-item>
<v-switch v-model="optionMPort" color="primary" :label="$t('rule.portRange')" hide-details></v-switch>
</v-list-item>
</template>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
import Headers from '@/components/Headers.vue'
import { i18n } from '@/locales'
export default {
props: ['direction', 'data'],
data() {
return {
menu: false,
masqTypes: [
{ title: i18n.global.t('rule.simple'), value: '' },
{ title: "File server", value: "file" },
{ title: "Reverse Proxy", value: "proxy" },
{ title: "Fixed response", value: "string" },
]
}
},
computed: {
down_mbps: {
get() { return this.$props.data.down_mbps?? 0 },
set(v:number) { this.$props.data.down_mbps = v>0 ? v : undefined }
},
up_mbps: {
get() { return this.$props.data.up_mbps?? 0 },
set(v:number) { this.$props.data.up_mbps = v>0 ? v : undefined }
},
server_ports: {
get() { return this.$props.data.server_ports?.join(',')?? [] },
set(v:string) { this.$props.data.server_ports = v.length > 0 ? v.split(',') : undefined }
},
masqueradeType: {
get() { return typeof this.$props.data.masquerade === 'object' ? this.$props.data.masquerade.type?? '' : '' },
set(v:string) {
if (v == '') {
this.$props.data.masquerade = ''
} else {
this.$props.data.masquerade = { type: v }
}
}
},
hop_interval: {
get() { return this.$props.data.hop_interval? parseInt(this.$props.data.hop_interval.replace('s','')) : 0 },
set(v:number) { this.$props.data.hop_interval = v>0 ? v + 's' : undefined }
},
optionObfs: {
get(): boolean { return this.$props.data.obfs != undefined },
set(v:boolean) { this.$props.data.obfs = v ? { type: "salamander", password: "" } : undefined }
},
optionMasq: {
get(): boolean { return this.$props.data.masquerade != undefined },
set(v:boolean) { this.$props.data.masquerade = v ? "" : undefined }
},
optionMPort: {
get(): boolean { return this.$props.data.server_ports != undefined },
set(v:boolean) { this.$props.data.server_ports = v ? [] : undefined }
}
},
components: { Network, Headers }
}
</script>
+138
View File
@@ -0,0 +1,138 @@
<template>
<v-card>
<v-card-subtitle v-if="direction != 'out_json'">Naive</v-card-subtitle>
<!-- Inbound -->
<template v-if="direction === 'in'">
<v-row>
<v-col cols="12" sm="6" md="4">
<Network :data="data" />
</v-col>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('types.naive.quicCongestion')"
:items="inbCngs"
v-model="data.quic_congestion_control"
@click:clear="delete data.quic_congestion_control"
clearable>
</v-select>
</v-col>
</v-row>
</template>
<!-- Outbound -->
<template v-if="['out', 'out_json'].includes(direction)">
<v-row v-if="direction === 'out'">
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.un')"
hide-details
v-model="data.username">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.pw')"
hide-details
type="password"
v-model="data.password">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.naive.insecureConcurrency')"
type="number"
min="0"
hide-details
v-model.number="insecure_concurrency">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="udpOverTcp" color="primary" :label="$t('types.naive.udpOverTcp')" hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="direction === 'out'">
<v-col cols="12" sm="6" md="4">
<v-switch v-model="data.quic" color="primary" :label="$t('types.naive.quic')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.quic">
<v-select
hide-details
:label="$t('types.naive.quicCongestion')"
:items="outCngs"
@click:clear="delete data.quic_congestion_control"
clearable
v-model="data.quic_congestion_control">
</v-select>
</v-col>
</v-row>
<Headers :data="extra_headers" />
</template>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
import Headers from '@/components/Headers.vue'
export default {
props: ['data', 'direction'],
data() {
return {
inbCngs: [
{ title: 'BBR', value: 'bbr'},
{ title: 'BBR Standard', value: 'bbr_standard'},
{ title: 'BBRv2', value: 'bbr2'},
{ title: 'BBRv2 variant', value: 'bbr2_variant'},
{ title: 'Cubic', value: 'cubic'},
{ title: 'New Reno', value: 'reno'},
],
outCngs: [
{ title: 'BBR', value: 'bbr'},
{ title: 'BBR2', value: 'bbr2'},
{ title: 'Cubic', value: 'cubic'},
{ title: 'Reno', value: 'reno'},
],
}
},
computed: {
udpOverTcp: {
get(): boolean {
const d = this.$props.data
return d?.udp_over_tcp === true || (d?.udp_over_tcp && (d.udp_over_tcp as any)?.enabled)
},
set(v: boolean) {
if (v) {
this.$props.data.udp_over_tcp = { enabled: true }
} else {
this.$props.data.udp_over_tcp = false
}
}
},
insecure_concurrency: {
get(): number { return this.$props.data?.insecure_concurrency ?? 0 },
set(v: number) {
this.$props.data.insecure_concurrency = v > 0 ? v : undefined
}
},
extra_headers(): any {
const d = this.$props.data
return new Proxy({}, {
get(_, prop) {
if (prop === 'headers') return d?.extra_headers ?? {}
return undefined
},
set(_, prop, value) {
if (prop === 'headers') {
d.extra_headers = value
return true
}
return false
}
})
},
},
components: { Network, Headers }
}
</script>
@@ -0,0 +1,44 @@
<template>
<v-card subtitle="ShadowTls">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:items="[1,2,3]"
:label="$t('version')"
v-model="version">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.version > 1">
<v-text-field
:label="$t('types.pw')"
hide-details
v-model="data.password">
</v-text-field>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
export default {
props: ['data'],
data() {
return {}
},
computed: {
version: {
get() { return this.$props.data.version ?? 3 },
set(v: number) {
this.$props.data.version = v
if (v==1) {
delete this.$props.data.password
} else if (this.$props.data.password === undefined ) {
this.$props.data.password = ""
}
}
},
}
}
</script>
@@ -0,0 +1,46 @@
<template>
<v-card subtitle="Selector">
<v-row>
<v-col cols="12" sm="6">
<v-combobox
v-model="data.outbounds"
:items="tags"
:label="$t('pages.outbounds')"
multiple
@update:model-value="updateDefault"
chips
hide-details
></v-combobox>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-combobox
v-model="data.default"
:items="data.outbounds"
:label="$t('types.lb.defaultOut')"
clearable
hide-details
></v-combobox>
</v-col>
<v-col cols="12" sm="6">
<v-switch v-model="data.interrupt_exist_connections" color="primary" :label="$t('types.lb.interruptConn')" hide-details></v-switch>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
export default {
props: ['data','tags'],
data() {
return {}
},
methods: {
updateDefault() {
if (!this.$props.data.outbounds?.includes(this.$props.data.default)) {
delete this.$props.data.default
}
}
},
}
</script>
@@ -0,0 +1,165 @@
<template>
<v-card subtitle="ShadowTls">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:items="[1,2,3]"
:label="$t('version')"
:disabled="data.id > 0"
v-model="version">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.password != undefined">
<v-text-field
:label="$t('types.pw')"
hide-details
v-model="data.password">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="Inbound.wildcard_sni != undefined">
<v-select label="Wildcard SNI" :items="['off', 'authed', 'all']" clearable v-model="Inbound.wildcard_sni"></v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.shdwTls.hs')"
hide-details
v-model="Inbound.handshake.server">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('out.port')"
type="number"
min="0"
hide-details
v-model.number="server_port">
</v-text-field>
</v-col>
</v-row>
<Dial :dial="Inbound.handshake" />
<v-row v-if="Inbound.handshake_for_server_name != undefined">
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.shdwTls.addHS')"
hide-details
v-model="handshake_server">
<template v-slot:append>
<v-chip
color="primary"
density="compact"
variant="elevated"
:disabled="handshake_server == ''"
@click="addHandshakeServer()">
<v-icon icon="mdi-plus" />
</v-chip>
</template>
</v-text-field>
</v-col>
</v-row>
<v-card
v-for="(value, key) in Inbound.handshake_for_server_name"
border
density="compact"
style="margin: 5px;"
color="background">
<v-card-title>
<v-row>
<v-col>{{ key }}
<v-icon icon="mdi-delete" color="error" size="small"
@click="Inbound.handshake_for_server_name ? delete Inbound.handshake_for_server_name[key] : null" />
</v-col>
</v-row>
</v-card-title>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.shdwTls.hs')"
hide-details
v-model="value.server">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('out.port')"
type="number"
min="0"
hide-details
v-model.number="value.server_port">
</v-text-field>
</v-col>
</v-row>
<Dial :dial="value" />
</v-card>
</v-card>
</template>
<script lang="ts">
import { ShadowTLS } from '@/types/inbounds'
import Dial from '../Dial.vue'
export default {
props: ['data'],
data() {
return {
handshake_server: ''
}
},
methods: {
addHandshakeServer() {
this.data.handshake_for_server_name[this.handshake_server] = {}
// Clear the input field after adding the server
this.handshake_server = ''
}
},
mounted() {
this.version = this.Inbound.version
},
computed: {
version: {
get() {
this.version = this.Inbound.version
return this.Inbound.version
},
set(newValue: any) {
switch (newValue) {
case 1:
delete this.Inbound.password
delete this.Inbound.handshake_for_server_name
delete this.Inbound.wildcard_sni
break
case 2:
if (!this.Inbound.password) {
this.Inbound.password = ""
}
if (!this.Inbound.handshake_for_server_name) {
this.Inbound.handshake_for_server_name = {}
}
delete this.Inbound.wildcard_sni
break
case 3:
delete this.Inbound.password
if (!this.Inbound.handshake_for_server_name) {
this.Inbound.handshake_for_server_name = {}
}
if (!this.Inbound.wildcard_sni) {
this.Inbound.wildcard_sni = ""
}
break
}
this.Inbound.version = newValue
}
},
Inbound(): ShadowTLS {
return <ShadowTLS>this.$props.data
},
server_port: {
get() { return this.Inbound.handshake.server_port ? this.Inbound.handshake.server_port : 443 },
set(newValue: any) { this.Inbound.handshake.server_port = newValue.length == 0 || newValue == 0 ? 443 : parseInt(newValue) }
},
},
components: { Dial }
}
</script>
@@ -0,0 +1,77 @@
<template>
<v-card subtitle="Shadowsocks">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('in.ssMethod')"
:items="ssMethods"
@update:model-value="direction == 'in' ? changeMethod($event) : undefined"
v-model="data.method">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<Network :data="data" />
</v-col>
<v-col cols="12" sm="6" md="4" v-if="direction == 'out'">
<UoT :data="data" />
</v-col>
<v-col cols="12" sm="6" md="4" v-if="direction == 'in'">
<v-switch
v-model="data.managed"
color="primary"
:label="$t('in.ssManageable')"
hide-details>
</v-switch>
</v-col>
</v-row>
<v-row v-if="data.method != 'none' || direction == 'out'">
<v-col cols="12" sm="8">
<v-text-field
v-model="data.password"
:label="$t('types.pw')"
hide-details
:append-inner-icon="direction == 'in' ? 'mdi-refresh' : undefined"
@click:append-inner="changeMethod(data.method)">
</v-text-field>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
import UoT from '@/components/UoT.vue'
import RandomUtil from '@/plugins/randomUtil'
export default {
props: ['direction','data'],
data() {
return {
ssMethods: [
"none",
"aes-128-gcm",
"aes-192-gcm",
"aes-256-gcm",
"chacha20-ietf-poly1305",
"xchacha20-ietf-poly1305",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305"
]
}
},
methods: {
changeMethod(ssMethod :string) {
if (ssMethod.startsWith('2022')) {
this.$props.data.password = ssMethod == "2022-blake3-aes-128-gcm" ? RandomUtil.randomShadowsocksPassword(16) : RandomUtil.randomShadowsocksPassword(32)
} else if (ssMethod == 'none') {
delete this.$props.data.password
} else {
this.$props.data.password = RandomUtil.randomSeq(10)
}
}
},
components: { Network, UoT }
}
</script>
@@ -0,0 +1,59 @@
<template>
<v-card subtitle="SOCKS">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.un')"
hide-details
v-model="username">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.pw')"
hide-details
v-model="password">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:items="['4','4a','5']"
:label="$t('version')"
v-model="data.version">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<Network :data="data" />
</v-col>
<v-col cols="12" sm="6" md="4">
<UoT :data="data" />
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
import UoT from '@/components/UoT.vue'
export default {
props: ['data'],
data() {
return {}
},
computed: {
username: {
get(): string { return this.data.username?.length > 0 ? this.data.username : '' },
set(v:string) { this.data.username = v.length > 0 ? v : undefined },
},
password: {
get(): string { return this.data.password?.length > 0 ? this.data.password : '' },
set(v:string) { this.data.password = v.length > 0 ? v : undefined },
},
},
components: { Network, UoT }
}
</script>
+151
View File
@@ -0,0 +1,151 @@
<template>
<v-card subtitle="SSH">
<template v-if="optionKey">
<v-row>
<v-col cols="auto">
<v-btn-toggle v-model="usePath"
class="rounded-xl"
density="compact"
variant="outlined"
shaped
mandatory>
<v-btn
@click="data.private_key=undefined; data.private_key_path=''"
>{{ $t('tls.usePath') }}</v-btn>
<v-btn
@click="data.private_key_path=undefined; data.private_key=''"
>{{ $t('tls.useText') }}</v-btn>
</v-btn-toggle>
</v-col>
</v-row>
<v-row v-if="usePath == 0">
<v-col cols="12" sm="6">
<v-text-field
:label="$t('tls.keyPath')"
hide-details
v-model="data.private_key_path">
</v-text-field>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12" sm="6">
<v-textarea
:label="$t('tls.key')"
hide-details
v-model="data.private_key">
</v-textarea>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6">
<v-text-field
:label="$t('types.ssh.passphrase')"
hide-details
v-model="data.private_key_passphrase">
</v-text-field>
</v-col>
</v-row>
</template>
<template v-else>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="data.user" :label="$t('types.un')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="data.password" :label="$t('types.pw')" hide-details></v-text-field>
</v-col>
</v-row>
</template>
<v-row v-if="optionHostKey">
<v-col cols="12" sm="6">
<v-textarea
:label="$t('types.ssh.hostKey')"
hide-details
v-model="host_key">
</v-textarea>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="data.host_key_algorithms != undefined">
<v-text-field v-model="algorithms" :label="$t('types.ssh.algorithm') + ' ' + $t('commaSeparated')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.client_version != undefined">
<v-text-field v-model="data.client_version" :label="$t('types.ssh.clientVer')" hide-details></v-text-field>
</v-col>
</v-row>
<v-card-actions>
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('types.ssh.options') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionKey" color="primary" label="SSH Key" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionHostKey" color="primary" :label="$t('types.ssh.hostKey')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionAlgorithms" color="primary" :label="$t('types.ssh.algorithm')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionVer" color="primary" :label="$t('types.ssh.clientVer')" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
export default {
props: ['data'],
data() {
return {
menu: false,
usePath: 0,
}
},
computed: {
optionKey: {
get(): boolean { return this.data.private_key != undefined || this.data.private_key_path != undefined },
set(v:boolean) {
this.usePath = 0
if (v) {
this.$props.data.private_key_path = ""
delete this.$props.data.user
delete this.$props.data.password
} else {
delete this.$props.data.private_key_path
delete this.$props.data.private_key
delete this.$props.data.private_key_passphrase
}
}
},
optionHostKey: {
get(): boolean { return this.data.host_key != undefined },
set(v:boolean) { this.data.host_key = v ? '' : undefined }
},
optionAlgorithms: {
get(): boolean { return this.data.host_key_algorithms != undefined },
set(v:boolean) { this.data.host_key_algorithms = v ? [] : undefined }
},
optionVer: {
get(): boolean { return this.data.client_version != undefined },
set(v:boolean) { this.data.client_version = v ? 'SSH-2.0-OpenSSH_7.4p1' : undefined }
},
host_key: {
get(): string { return this.$props.data.host_key ? this.$props.data.host_key.join('\n') : '' },
set(v:string) { this.$props.data.host_key = v.split('\n') }
},
algorithms: {
get() { return this.$props.data.host_key_algorithms ? this.$props.data.host_key_algorithms.join(',') : '' },
set(v:string) { this.$props.data.host_key_algorithms = v.length > 0 ? v.split(',') : undefined }
},
},
}
</script>
@@ -0,0 +1,22 @@
<template>
<v-card subtitle="TProxy">
<v-row>
<v-col cols="12" sm="6" md="4">
<Network :data="inbound" />
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
export default {
props: ['inbound'],
data() {
return {
}
},
components: { Network }
}
</script>
@@ -0,0 +1,200 @@
<template>
<v-card subtitle="Talescale">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="data.ephemeral" :label="$t('types.ts.ephemeral')"></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="data.accept_routes" :label="$t('types.ts.acceptRoutes')"></v-switch>
</v-col>
</v-row>
<v-row v-if="optionStateDir">
<v-col cols="12" sm="8">
<v-text-field v-model="data.state_directory" :label="$t('types.ts.stateDir')"></v-text-field>
</v-col>
</v-row>
<v-row v-if="optionAuth">
<v-col cols="12" sm="8">
<v-text-field v-model="data.auth_key" :label="$t('types.ts.authKey')"></v-text-field>
</v-col>
</v-row>
<v-row v-if="optionCtrlUrl">
<v-col cols="12" sm="8">
<v-text-field v-model="data.control_url" :label="$t('types.ts.controlUrl')"></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="optionHostname">
<v-text-field v-model="data.hostname" :label="$t('types.ts.hostname')"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionUdpTimeout">
<v-text-field type="number" v-model.number="udpTimeout" min="1" :suffix="$t('date.s')" :label="$t('types.ts.udpTimeout')"></v-text-field>
</v-col>
</v-row>
<v-row v-if="optionExitNode">
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="data.exit_node" :label="$t('types.ts.exitNode')"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="data.exit_node_allow_lan_access" :label="$t('types.ts.allowLanAccess')"></v-switch>
</v-col>
</v-row>
<v-row v-if="optionRelay">
<v-col cols="12" sm="6" md="4">
<v-text-field v-model.number="data.relay_server_port" type="number" min="0" :label="$t('types.ts.relayServerPort')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="8">
<v-text-field v-model="relay_endpoints" :label="$t('types.ts.relayEndpoints') + ' ' + $t('commaSeparated')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row v-if="optionSysIf">
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="data.system_interface" :label="$t('types.ts.systemInterface')"></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.system_interface">
<v-text-field v-model="data.system_interface_name" :label="$t('types.ts.sysIfName')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.system_interface">
<v-text-field v-model.number="data.system_interface_mtu" type="number" min="0" :label="$t('types.ts.sysIfMtu')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row v-if="optionAdvRoutes">
<v-col cols="12" sm="8">
<v-text-field v-model="advertise_routes" :label="$t('types.ts.advRoutes') + ' ' + $t('commaSeparated')"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="data.advertise_exit_node" :label="$t('types.ts.advExitNode')"></v-switch>
</v-col>
</v-row>
<v-card-actions>
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('types.ts.options') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionStateDir" color="primary" :label="$t('types.ts.stateDir')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionAuth" color="primary" :label="$t('types.ts.authKey')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionCtrlUrl" color="primary" :label="$t('types.ts.controlUrl')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionHostname" color="primary" :label="$t('types.ts.hostname')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionExitNode" color="primary" :label="$t('types.ts.exitNode')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionRelay" color="primary" :label="$t('types.ts.relayServer')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionSysIf" color="primary" :label="$t('types.ts.systemInterface')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionAdvRoutes" color="primary" :label="$t('types.ts.advRoutes')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionUdpTimeout" color="primary" :label="$t('types.ts.udpTimeout')" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
export default {
props: ['data'],
data() {
return {
menu: false,
}
},
computed: {
optionStateDir: {
get() { return this.$props.data?.state_directory !== undefined },
set(v: boolean) { this.$props.data.state_directory = v ? "$HOME/.tailscale" : undefined }
},
optionAuth: {
get() { return this.$props.data?.auth_key !== undefined },
set(v: boolean) { this.$props.data.auth_key = v ? "" : undefined }
},
optionCtrlUrl: {
get() { return this.$props.data?.control_url !== undefined },
set(v: boolean) { this.$props.data.control_url = v ? "https://controlplane.tailscale.com" : undefined }
},
optionHostname: {
get() { return this.$props.data?.hostname !== undefined },
set(v: boolean) { this.$props.data.hostname = v ? "localhost" : undefined }
},
optionExitNode: {
get() { return this.$props.data?.exit_node !== undefined },
set(v: boolean) {
if (v) {
this.$props.data.exit_node = ""
} else {
delete this.$props.data.exit_node
delete this.$props.data.exit_node_allow_lan_access
}
}
},
optionAdvRoutes: {
get() { return this.$props.data?.advertise_routes !== undefined },
set(v: boolean) {
if (v) {
this.$props.data.advertise_routes = []
} else {
delete this.$props.data.advertise_routes
delete this.$props.data.advertise_exit_node
}
}
},
optionRelay: {
get() { return this.$props.data?.relay_server_port !== undefined || (this.$props.data?.relay_server_static_endpoints?.length ?? 0) > 0 },
set(v: boolean) {
if (v) {
this.$props.data.relay_server_port = 0
this.$props.data.relay_server_static_endpoints = []
} else {
delete this.$props.data.relay_server_port
delete this.$props.data.relay_server_static_endpoints
}
}
},
optionSysIf: {
get() { return this.$props.data?.system_interface !== undefined },
set(v: boolean) {
if (v) {
this.$props.data.system_interface = false
} else {
delete this.$props.data.system_interface
delete this.$props.data.system_interface_name
delete this.$props.data.system_interface_mtu
}
}
},
optionUdpTimeout: {
get() { return this.$props.data?.udp_timeout !== undefined },
set(v: boolean) { this.$props.data.udp_timeout = v ? '30s' : undefined }
},
udpTimeout: {
get() { return this.$props.data?.udp_timeout? this.$props.data.udp_timeout.replace('s','') : '' },
set(v: number) { this.$props.data.udp_timeout = v>1 ? v + 's' : '30s' }
},
advertise_routes: {
get() { return this.$props.data?.advertise_routes?.join(',') ?? "" },
set(v: string) { this.$props.data.advertise_routes = v.length > 0 ? v.split(',') : [] }
},
relay_endpoints: {
get() { return this.$props.data?.relay_server_static_endpoints?.join(',') ?? "" },
set(v: string) { this.$props.data.relay_server_static_endpoints = v.length > 0 ? v.split(',') : [] }
},
},
}
</script>
+115
View File
@@ -0,0 +1,115 @@
<template>
<v-card subtitle="Tor">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="data.executable_path" :label="$t('types.tor.execPath')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="data.data_directory" :label="$t('types.tor.dataDir')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="extra_args" :label="$t('types.tor.extArgs') + ' ' + $t('commaSeparated')" hide-details></v-text-field>
</v-col>
</v-row>
<div class="v-card-subtitle" style="margin: 10px;">Torrc
<v-chip color="primary" density="compact" variant="elevated" @click="add_torrc_option"><v-icon icon="mdi-plus" /></v-chip>
</div>
<v-row v-for="(torrc, index) in torrc_options">
<v-col cols="auto" align-self="center" justify-self="center">
<v-icon @click="del_torrc_option(index)" color="error" icon="mdi-delete" />
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('objects.key')"
hide-details
@input="update_key(index,$event.target.value)"
v-model="torrc.name">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('objects.value')"
hide-details
@input="update_value(index,$event.target.value)"
v-model="torrc.value">
</v-text-field>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
type torrc_option = {
name: string
value: string
}
export default {
props: ['data'],
data() {
return {}
},
methods: {
add_torrc_option() {
this.torrc_options = [...this.torrc_options, {name: "", value: ""}]
},
del_torrc_option(i:number) {
let h = this.torrc_options
h.splice(i,1)
this.torrc_options = h
},
update_key(i:number,k:string) {
let h = this.torrc_options
h[i].name = k
this.torrc_options = h
},
update_value(i:number,v:string) {
let h = this.torrc_options
h[i].value = v
this.torrc_options = h
},
},
computed: {
torrc_options: {
get() :torrc_option[] {
let options: torrc_option[] = []
const h = this.$props.data.torrc
if (h) {
Object.keys(h).forEach(key => {
if (Array.isArray(h[key])){
h[key].forEach((v:string) => options.push({ name: key, value: v }))
} else {
options.push({ name: key, value: h[key] })
}
})
}
return options
},
set(v:torrc_option[]) {
if (v.length>0) {
let torrc:any = {}
v.forEach((h:torrc_option) => {
if (torrc[h.name]) {
if (Array.isArray(torrc[h.name])) {
torrc[h.name].push(h.value)
} else {
torrc[h.name] = [torrc[h.name], h.value]
}
} else {
torrc[h.name] = h.value
}
})
this.$props.data.torrc = torrc
} else {
this.$props.data.torrc = undefined
}
}
},
extra_args: {
get() { return this.$props.data.extra_args?.join(',') },
set(v:string) { this.$props.data.extra_args = v.length > 0 ? v.split(',') : undefined }
},
},
}
</script>
@@ -0,0 +1,24 @@
<template>
<v-card subtitle="Trojan">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="data.password" :label="$t('types.pw')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<Network :data="data" />
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
export default {
props: ['data'],
data() {
return {}
},
components: { Network }
}
</script>
@@ -0,0 +1,89 @@
<template>
<v-card subtitle="TUIC">
<v-row v-if="direction == 'out'">
<v-col cols="12" sm="6">
<v-text-field v-model="data.uuid" label="UUID" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="data.password" :label="$t('types.pw')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<Network :data="data" />
</v-col>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
label="UDP Relay Mode"
:items="['native', 'quic']"
clearable
@click:clear="delete data.udp_relay_mode"
v-model="data.udp_relay_mode">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" label="UDP Over Stream" v-model="data.udp_over_stream" hide-details></v-switch>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('types.tuic.congControl')"
:items="congestion_controls"
v-model="data.congestion_control">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" label="Zero-RTT Handshake" v-model="data.zero_rtt_handshake" hide-details></v-switch>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="direction == 'in'">
<v-text-field
:label="$t('types.tuic.authTimeout')"
hide-details
type="number"
:suffix="$t('date.s')"
min="1"
v-model.number="auth_timeout">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.tuic.hb')"
hide-details
type="number"
:suffix="$t('date.s')"
min="1"
v-model.number="heartbeat">
</v-text-field>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
export default {
props: ['direction', 'data'],
data() {
return {
congestion_controls: [
"cubic","new_reno", "bbr"
]
}
},
computed: {
auth_timeout: {
get() { return this.$props.data.auth_timeout ? parseInt(this.$props.data.auth_timeout.replace('s','')) : '' },
set(newValue:number) { this.$props.data.auth_timeout = newValue ? newValue + 's' : '' }
},
heartbeat: {
get() { return this.$props.data.heartbeat ? parseInt(this.$props.data.heartbeat.replace('s','')) : '' },
set(newValue:number) { this.$props.data.heartbeat = newValue ? newValue + 's' : '' }
}
},
components: { Network }
}
</script>
+100
View File
@@ -0,0 +1,100 @@
<template>
<v-card subtitle="Tun">
<v-row>
<v-col cols="12" sm="8">
<v-text-field v-model="addrs" :label="$t('types.tun.addr') + ' ' + $t('commaSeparated')" placeholder="172.18.0.1/30" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="data.interface_name" :label="$t('types.tun.ifName')" placeholder="tun0" hide-details clearable @click:clear="delete data.interface_name"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field type="number" v-model.number="data.mtu" label="MTU" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
type="number"
v-model.number="udpTimeout"
label="UDP timeout"
min="1"
:suffix="$t('date.m')"
hide-details>
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-select
v-model="data.stack"
label="Stack"
:items="['system','gvisor','mixed']"
hide-details
></v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="data.endpoint_independent_nat" color="primary" label="Independent NAT" hide-details></v-switch>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="autoRoute" color="primary" label="Auto Route" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="autoRoute">
<v-switch v-model="data.auto_redirect" color="primary" label="Auto Redirect" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="autoRoute">
<v-switch v-model="data.strict_route" color="primary" label="Strict Route" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="autoRoute && data.auto_redirect">
<v-switch v-model="data.exclude_mptcp" color="primary" :label="$t('types.tun.excludeMptcp')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="autoRoute && data.auto_redirect">
<v-text-field
type="number"
v-model.number="fallbackRuleIndex"
:label="$t('types.tun.fallbackRuleIndex')"
min="0"
hide-details>
</v-text-field>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
export default {
props: ['data'],
data() {
return {
menu: false
}
},
computed: {
addrs: {
get() { return this.$props.data.address?.join(',') },
set(v:string) { this.$props.data.address = v.length > 0 ? v.split(',') : undefined }
},
udpTimeout: {
get() { return this.$props.data.udp_timeout ? parseInt(this.$props.data.udp_timeout.replace('m','')) : 5 },
set(v:number) { this.$props.data.udp_timeout = v > 0 ? v + 'm' : '5m' }
},
autoRoute: {
get() { return this.$props.data.auto_route ?? false },
set(v:boolean) {
this.$props.data.auto_route = v
this.$props.data.auto_redirect = v ? false : undefined
this.$props.data.strict_route = v ? false : undefined
}
},
fallbackRuleIndex: {
get() { return this.$props.data.auto_redirect_iproute2_fallback_rule_index ?? 32768 },
set(v: number) {
const val = typeof v === 'number' && !isNaN(v) && v >= 0 ? v : undefined
this.$props.data.auto_redirect_iproute2_fallback_rule_index = val
}
}
}
}
</script>
@@ -0,0 +1,121 @@
<template>
<v-card subtitle="URL Test">
<v-row>
<v-col cols="12" sm="6">
<v-combobox
v-model="data.outbounds"
:items="tags"
:label="$t('pages.outbounds')"
multiple
chips
hide-details
></v-combobox>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" v-if="optionUrl">
<v-text-field v-model="data.url" :label="$t('types.lb.testUrl')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="optionInterval">
<v-text-field
:label="$t('types.lb.interval')"
hide-details
type="number"
min="3"
:suffix="$t('date.s')"
v-model.number="interval"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionTolerance">
<v-text-field
:label="$t('types.lb.tolerance')"
hide-details
type="number"
min="0"
:suffix="$t('date.ms')"
v-model.number="tolerance"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionIdle">
<v-text-field
:label="$t('transport.idleTimeout')"
hide-details
type="number"
min="0"
:suffix="$t('date.m')"
v-model.number="idle_timeout"></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6">
<v-switch v-model="data.interrupt_exist_connections" color="primary" :label="$t('types.lb.interruptConn')" hide-details></v-switch>
</v-col>
</v-row>
<v-card-actions>
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('types.lb.urlTestOptions') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionUrl" color="primary" :label="$t('types.lb.testUrl')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionInterval" color="primary" :label="$t('types.lb.interval')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionTolerance" color="primary" :label="$t('types.lb.tolerance')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionIdle" color="primary" :label="$t('transport.idleTimeout')" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
export default {
props: ['data', 'tags'],
data() {
return {
menu: false,
}
},
computed: {
optionUrl: {
get(): boolean { return this.$props.data.url != undefined },
set(v:boolean) { this.$props.data.url = v ? 'https://www.gstatic.com/generate_204' : undefined }
},
optionInterval: {
get(): boolean { return this.$props.data.interval != undefined },
set(v:boolean) { this.$props.data.interval = v ? '3s' : undefined }
},
optionTolerance: {
get(): boolean { return this.$props.data.tolerance != undefined },
set(v:boolean) { this.$props.data.tolerance = v ? 50 : undefined }
},
optionIdle: {
get(): boolean { return this.$props.data.idle_timeout != undefined },
set(v:boolean) { this.$props.data.idle_timeout = v ? '30m' : undefined }
},
interval: {
get() { return this.$props.data.interval ? parseInt(this.$props.data.interval.replace('s','')) : 3 },
set(v:number) { this.$props.data.interval = v > 0 ? v + 's' : '3s' }
},
tolerance: {
get() { return this.$props.data.tolerance ? parseInt(this.$props.data.tolerance) : 0 },
set(v:number) { this.$props.data.tolerance = v > 0 ? v : 0 }
},
idle_timeout: {
get() { return this.$props.data.idle_timeout ? parseInt(this.$props.data.idle_timeout.replace('m','')) : 30 },
set(v:number) { this.$props.data.idle_timeout = v > 0 ? v + 'm' : '0m' }
}
},
}
</script>
@@ -0,0 +1,48 @@
<template>
<v-card subtitle="VLESS">
<v-row>
<v-col cols="12" sm="6">
<v-text-field v-model="data.uuid" label="UUID" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('types.vless.flow')"
:items="['','xtls-rprx-vision']"
v-model="data.flow">
</v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('types.vless.udpEnc')"
:items="['none','packetaddr','xudp']"
v-model="packet_encoding">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<Network :data="data" />
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
export default {
props: ['data'],
data() {
return {}
},
computed: {
packet_encoding: {
get() { return this.$props.data.packet_encoding != undefined ? this.$props.data.packet_encoding : 'none' },
set(newValue:string) { this.$props.data.packet_encoding = newValue != "none" ? newValue : undefined }
},
},
components: { Network }
}
</script>
@@ -0,0 +1,72 @@
<template>
<v-card subtitle="VMESS">
<v-row>
<v-col cols="12" sm="6">
<v-text-field v-model="data.uuid" label="UUID" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Alter ID"
hide-details
type="number"
min=0
v-model.number="data.alter_id">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('types.vmess.security')"
:items="securities"
v-model="data.security">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('types.vless.udpEnc')"
:items="['none','packetaddr','xudp']"
v-model="packet_encoding">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<Network :data="data" />
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="data.global_padding" color="primary" :label="$t('types.vmess.globalPadding')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="data.authenticated_length" color="primary" :label="$t('types.vmess.authLen')" hide-details></v-switch>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
export default {
props: ['data'],
data() {
return {
securities: [
"auto",
"none",
"zero",
"aes-128-gcm",
"aes-128-ctr",
"chacha20-poly1305",
]
}
},
computed: {
packet_encoding: {
get() { return this.$props.data.packet_encoding != undefined ? this.$props.data.packet_encoding : 'none' },
set(newValue:string) { this.$props.data.packet_encoding = newValue != "none" ? newValue : undefined }
},
},
components: { Network }
}
</script>
+170
View File
@@ -0,0 +1,170 @@
<template>
<v-card subtitle="Warp">
<template v-if="data.id>0">
<table dir="ltr" width="100%">
<tbody>
<tr>
<td>Device ID</td>
<td>{{ data.ext.device_id }}</td>
</tr>
<tr>
<td>Access Token</td>
<td>{{ data.ext.access_token }}</td>
</tr>
<tr>
<td>{{ $t('types.wg.privKey') }}</td>
<td>{{ data.private_key }}</td>
</tr>
<tr>
<td>{{ $t('types.wg.localIp') }}</td>
<td>{{ data.address.join(',') }}</td>
</tr>
<tr>
<td colspan="2">
<v-text-field
v-model="data.ext.license_key"
label="License Key"
hide-details>
</v-text-field>
</td>
</tr>
</tbody>
</table>
<v-card :subtitle="$t('types.wg.peer')">
<v-row>
<v-col cols="12" sm="6">
<v-text-field
:label="$t('out.addr')"
hide-details
v-model="data.peers[0].address">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('out.port')"
hide-details
type="number"
min=1
v-model.number="data.peers[0].port">
</v-text-field>
</v-col>
</v-row>
<table dir="ltr" width="100%">
<tbody>
<tr>
<td>{{ $t('types.wg.pubKey') }}</td>
<td>{{ data.peers[0].public_key }}</td>
</tr>
<tr>
<td>{{ $t('types.wg.allowedIp') }}</td>
<td>{{ data.peers[0].allowed_ips.join(',') }}</td>
</tr>
<tr>
<td>Reserved</td>
<td>[{{ data.peers[0].reserved.join(',') }}]</td>
</tr>
</tbody>
</table>
</v-card>
</template>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="data.udp_timeout != undefined">
<v-text-field
label="UDP Timeout"
hide-details
type="number"
min=0
:suffix="$t('date.m')"
v-model.number="udp_timeout">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.workers != undefined">
<v-text-field
:label="$t('types.wg.worker')"
hide-details
type="number"
min=1
v-model.number="data.workers">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.mtu != undefined">
<v-text-field
label="MTU"
hide-details
type="number"
min=0
v-model.number="data.mtu">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="data.system" color="primary" :label="$t('types.wg.sysIf')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.system">
<v-text-field
:label="$t('types.wg.ifName')"
hide-details
v-model="ifName">
</v-text-field>
</v-col>
</v-row>
<v-card-actions>
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('types.wg.options') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionUdp" color="primary" label="UDP Timeout" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionWorker" color="primary" :label="$t('types.wg.worker')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionMtu" color="primary" label="MTU" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
export default {
props: ['data'],
data() {
return {
menu: false,
}
},
methods: {
},
computed: {
optionUdp: {
get(): boolean { return this.$props.data.udp_timeout != undefined },
set(v:boolean) { this.$props.data.udp_timeout = v ? "5m" : undefined }
},
optionWorker: {
get(): boolean { return this.$props.data.workers != undefined },
set(v:boolean) { this.$props.data.workers = v ? 2 : undefined }
},
optionMtu: {
get(): boolean { return this.$props.data.mtu != undefined },
set(v:boolean) { this.$props.data.mtu = v ? 1408 : undefined }
},
ifName: {
get() { return this.$props.data.name?? '' },
set(v:string) { this.$props.data.name = v.length > 0 ? v : undefined }
},
udp_timeout: {
get() { return this.$props.data.udp_timeout ? parseInt(this.$props.data.udp_timeout.replace('m','')) : 5 },
set(v:number) { this.$props.data.udp_timeout = v > 0 ? v + 'm' : '5m' }
}
}
}
</script>
@@ -0,0 +1,197 @@
<template>
<v-card subtitle="Wireguard">
<v-row>
<v-col cols="12" sm="8">
<v-text-field
v-model="data.private_key"
:label="$t('types.wg.privKey')"
append-icon="mdi-key-star"
@click:append="newKey()"
hide-details>
</v-text-field>
</v-col>
<v-col cols="12" sm="8">
<v-text-field
v-model="public_key"
readonly
:label="$t('tls.pubKey')"
append-icon="mdi-refresh"
@click:append="getWgPubKey()"
hide-details>
</v-text-field>
</v-col>
<v-col cols="12" sm="8">
<v-text-field v-model="address" :label="$t('types.wg.localIp') + ' ' + $t('commaSeparated')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('in.port')"
hide-details
type="number"
min=1
v-model.number="data.listen_port">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.udp_timeout != undefined">
<v-text-field
label="UDP Timeout"
hide-details
type="number"
min=0
:suffix="$t('date.m')"
v-model.number="udp_timeout">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="data.workers != undefined">
<v-text-field
:label="$t('types.wg.worker')"
hide-details
type="number"
min=1
v-model.number="data.workers">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.mtu != undefined">
<v-text-field
label="MTU"
hide-details
type="number"
min=0
v-model.number="data.mtu">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="8">
<v-text-field v-model="data.ext.dns" :label="$t('dns.title') + ' ' + $t('commaSeparated')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="data.system" color="primary" :label="$t('types.wg.sysIf')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="data.system">
<v-text-field
:label="$t('types.wg.ifName')"
hide-details
v-model="ifName">
</v-text-field>
</v-col>
</v-row>
<v-card-actions>
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('types.wg.options') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionUdp" color="primary" label="UDP Timeout" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionWorker" color="primary" :label="$t('types.wg.worker')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionMtu" color="primary" label="MTU" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
<v-card v-if="data.peers != undefined">
<v-card-subtitle>
{{ $t('types.wg.peers') }}
<v-chip color="primary" density="compact" variant="elevated" @click="addPeer"><v-icon icon="mdi-plus" /></v-chip>
</v-card-subtitle>
<template v-for="(p, index) in data.peers">
<v-card style="margin-top: 1rem;">
<v-card-subtitle>
{{ $t('types.wg.peer') + ' ' + (Number(index)+1) }} <v-icon color="error" icon="mdi-delete" @click="delPeer(Number(index))" />
</v-card-subtitle>
<Peer :data="p" :ext="data.ext" @refreshPeerKey="$emit('refreshPeerKey', index)" />
</v-card>
</template>
</v-card>
</template>
<script lang="ts">
import Peer from '@/components/WgPeer.vue'
export default {
props: ['data'],
emits: ['newWgKey', 'getWgPubKey', 'addPeer', 'delPeer', 'refreshPeerKey'],
data() {
return {
menu: false,
}
},
methods: {
addPeer() {
this.$emit('addPeer')
},
delPeer(id: number) {
this.$emit('delPeer', id)
},
refreshPeerKey(id: number) {
this.$emit('refreshPeerKey', id)
},
newKey() {
this.$emit('newWgKey')
},
getWgPubKey() {
const privKey = this.$props.data.private_key
if (privKey.length == 0) return
this.$emit('getWgPubKey', privKey)
},
},
computed: {
optionUdp: {
get(): boolean { return this.$props.data.udp_timeout != undefined },
set(v:boolean) { this.$props.data.udp_timeout = v ? "5m" : undefined }
},
optionRsrv: {
get(): boolean { return this.$props.data.reserved != undefined },
set(v:boolean) { this.$props.data.reserved = v ? [0,0,0] : undefined }
},
optionWorker: {
get(): boolean { return this.$props.data.workers != undefined },
set(v:boolean) { this.$props.data.workers = v ? 2 : undefined }
},
optionMtu: {
get(): boolean { return this.$props.data.mtu != undefined },
set(v:boolean) { this.$props.data.mtu = v ? 1408 : undefined }
},
ifName: {
get() { return this.$props.data.name?? '' },
set(v:string) { this.$props.data.name = v.length > 0 ? v : undefined }
},
address: {
get() { return this.$props.data.address?.join(',') },
set(v:string) { this.$props.data.address = v.length > 0 ? v.split(',') : undefined }
},
reserved: {
get() { return this.$props.data.reserved?.join(',') },
set(v:string) {
if(!v.endsWith(',')) {
this.$props.data.reserved = v.length > 0 ? v.split(',').map(str => parseInt(str, 10)) : []
}
}
},
udp_timeout: {
get() { return this.$props.data.udp_timeout ? parseInt(this.$props.data.udp_timeout.replace('m','')) : 5 },
set(v:number) { this.$props.data.udp_timeout = v > 0 ? v + 'm' : '5m' }
},
public_key: {
get() { return this.$props.data.ext?.public_key?? '' },
set(v:string) { this.$props.data.ext.public_key = v }
}
},
components: { Peer }
}
</script>
+67
View File
@@ -0,0 +1,67 @@
<template>
<v-card subtitle="CCM (Claude Code Multiplexer)">
<v-row>
<v-col cols="12" sm="6">
<v-text-field
:label="$t('types.ccm.credentialPath')"
hide-details
v-model="data.credential_path">
</v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
:label="$t('types.ccm.usagesPath')"
hide-details
v-model="data.usages_path">
</v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-select
:label="$t('dial.detourText')"
hide-details
:items="outTags"
v-model="data.detour">
</v-select>
</v-col>
</v-row>
<v-card-title>
{{ $t('types.ccm.users') }}
<v-chip color="primary" density="compact" variant="elevated" @click="addUser"><v-icon icon="mdi-plus" /></v-chip>
</v-card-title>
<v-card v-for="(user, index) in (data.users || [])" :key="index" class="border" style="margin: 4px; padding: 8px;" rounded="xl">
<v-row>
<v-col cols="auto" align-self="center">
<v-icon @click="delUser(index)" color="error" icon="mdi-delete" />
</v-col>
<v-col cols="12" sm="4">
<v-text-field :label="$t('types.ccm.userName')" hide-details v-model="user.name" />
</v-col>
<v-col cols="12" sm="6">
<v-text-field :label="$t('types.ccm.userToken')" hide-details type="password" v-model="user.token" />
</v-col>
</v-row>
</v-card>
</v-card>
</template>
<script lang="ts">
import Data from '@/store/modules/data'
export default {
props: ['data'],
computed: {
outTags() {
return [...Data().outbounds?.map((o: any) => o.tag) ?? [], ...Data().endpoints?.map((e: any) => e.tag) ?? []]
},
},
methods: {
addUser() {
if (!this.$props.data.users) this.$props.data.users = []
this.$props.data.users.push({ name: '', token: '' })
},
delUser(i: number | string) {
this.$props.data.users?.splice(Number(i), 1)
},
},
}
</script>
+219
View File
@@ -0,0 +1,219 @@
<template>
<v-card subtitle="DERP">
<v-row>
<v-col cols="12" sm="8">
<v-text-field
:label="$t('types.derp.configPath')"
hide-details
v-model="data.config_path">
</v-text-field>
</v-col>
</v-row>
<v-row v-if="optionHome">
<v-col cols="12" sm="8">
<v-text-field
:label="$t('pages.home')"
hide-details
placeholder="blank | http[s]://example.com:port/path"
v-model="data.home">
</v-text-field>
</v-col>
</v-row>
<v-row v-if="optionVerifyCE">
<v-col cols="12" sm="8">
<v-select
:label="$t('types.derp.verifyClientEndpoint')"
hide-details
:items="tsTags"
multiple
v-model="data.verify_client_endpoint">
</v-select>
</v-col>
</v-row>
<template v-if="optionVerifyCU">
<v-card-title>
<v-row>
<v-col>{{ $t('types.derp.verifyClientUrl') }}</v-col>
<v-col cols="auto" align-self="center" justify-self="center">
<v-chip color="primary" density="compact" variant="elevated" @click="data.verify_client_url.push({url: ''})"><v-icon icon="mdi-plus" /></v-chip>
</v-col>
</v-row>
</v-card-title>
<v-card v-for="clientUrl, index in data.verify_client_url" :key="index" class="border" style="padding: 8px;" rounded="xl">
<v-row>
<v-col cols="auto" align-self="center" justify-self="center">
<v-icon @click="data.verify_client_url.splice(index, 1)" color="error" icon="mdi-delete" />
</v-col>
<v-col cols="11">
<v-text-field
:label="$t('types.derp.verifyClientUrl')"
hide-details
v-model="clientUrl.url">
</v-text-field>
<Dial :dial="clientUrl" />
</v-col>
</v-row>
</v-card>
</template>
<template v-if="optionMesh">
<v-card-title>
<v-row>
<v-col>{{ $t('types.derp.meshWith') }}</v-col>
<v-col cols="auto" align-self="center" justify-self="center">
<v-chip color="primary" density="compact" variant="elevated" @click="data.mesh_with.push({tls: {}})"><v-icon icon="mdi-plus" /></v-chip>
</v-col>
</v-row>
</v-card-title>
<v-card v-for="mesh, index in data.mesh_with" :key="index" class="border" style="padding: 8px;" rounded="xl">
<v-row>
<v-col cols="auto" align-self="center" justify-self="center">
<v-icon @click="data.mesh_with.splice(index, 1)" color="error" icon="mdi-delete" />
</v-col>
<v-col cols="11">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('out.addr')"
hide-details
v-model="mesh.server">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('out.port')"
hide-details
type="number"
v-model.number="mesh.server_port">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.host')"
hide-details
v-model="mesh.host">
</v-text-field>
</v-col>
</v-row>
<Dial :dial="mesh" />
<OutTLS :outbound="mesh" />
</v-col>
</v-row>
</v-card>
<v-row>
<v-col cols="auto">
<v-btn-toggle v-model="usePskText"
class="rounded-xl"
density="compact"
variant="outlined"
shaped
mandatory>
<v-btn
@click="delete data.mesh_psk_file"
>{{ $t('types.derp.meshPsk') }}</v-btn>
<v-btn
@click="delete data.mesh_psk"
>{{ $t('types.derp.meshPskFile') }}</v-btn>
</v-btn-toggle>
</v-col>
</v-row>
<v-row v-if="usePskText == 1">
<v-col cols="12">
<v-text-field
:label="$t('types.derp.meshPskFile')"
hide-details
v-model="data.mesh_psk_file">
</v-text-field>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12">
<v-text-field
:label="$t('types.derp.meshPsk')"
hide-details
v-model="data.mesh_psk">
</v-text-field>
</v-col>
</v-row>
</template>
<template v-if="optionStun">
<v-card :title="$t('types.derp.stun')" class="border" style="padding: 8px;" rounded="xl">
<Listen :data="data.stun" :inTags="inTags" />
</v-card>
</template>
<v-card-actions>
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('types.derp.options') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionVerifyCE" color="primary" :label="$t('types.derp.verifyClientEndpoint')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionVerifyCU" color="primary" :label="$t('types.derp.verifyClientUrl')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionHome" color="primary" :label="$t('pages.home')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionMesh" color="primary" :label="$t('types.derp.meshWith')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionStun" color="primary" :label="$t('types.derp.stun')" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import Dial from '@/components/Dial.vue'
import OutTLS from '../tls/OutTLS.vue'
import Listen from '../Listen.vue'
export default {
props: ['data', 'tsTags', 'inTags'],
data() {
return {
menu: false,
usePskText: this.$props.data.mesh_psk == undefined ? 1 : 0,
}
},
computed: {
optionVerifyCE: {
get() { return this.$props.data.verify_client_endpoint != undefined },
set(v: boolean) { this.$props.data.verify_client_endpoint = v ? [] : undefined }
},
optionVerifyCU: {
get() { return this.$props.data.verify_client_url != undefined },
set(v: boolean) { this.$props.data.verify_client_url = v ? [{ url: '' }] : undefined }
},
optionHome: {
get() { return this.$props.data.home != undefined },
set(v: boolean) { this.$props.data.home = v ? '' : undefined }
},
optionMesh: {
get() { return this.$props.data.mesh_with != undefined },
set(v: boolean) {
if (v) {
this.$props.data.mesh_with = [{tls: {}}]
delete this.$props.data.mesh_psk_file
this.$props.data.mesh_psk = ''
} else {
delete this.$props.data.mesh_with
delete this.$props.data.mesh_psk_file
delete this.$props.data.mesh_psk
}
}
},
optionStun: {
get() { return this.$props.data.stun != undefined },
set(v: boolean) { this.$props.data.stun = v ? {enabled: true} : undefined }
}
},
components: { Dial, Listen, OutTLS },
}
</script>
+67
View File
@@ -0,0 +1,67 @@
<template>
<v-card subtitle="OCM (OpenAI Codex Multiplexer)">
<v-row>
<v-col cols="12" sm="6">
<v-text-field
:label="$t('types.ocm.credentialPath')"
hide-details
v-model="data.credential_path">
</v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
:label="$t('types.ocm.usagesPath')"
hide-details
v-model="data.usages_path">
</v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-select
:label="$t('dial.detourText')"
hide-details
:items="outTags"
v-model="data.detour">
</v-select>
</v-col>
</v-row>
<v-card-title>
{{ $t('types.ocm.users') }}
<v-chip color="primary" density="compact" variant="elevated" @click="addUser"><v-icon icon="mdi-plus" /></v-chip>
</v-card-title>
<v-card v-for="(user, index) in (data.users || [])" :key="index" class="border" style="margin: 4px; padding: 8px;" rounded="xl">
<v-row>
<v-col cols="auto" align-self="center">
<v-icon @click="delUser(index)" color="error" icon="mdi-delete" />
</v-col>
<v-col cols="12" sm="4">
<v-text-field :label="$t('types.ocm.userName')" hide-details v-model="user.name" />
</v-col>
<v-col cols="12" sm="6">
<v-text-field :label="$t('types.ocm.userToken')" hide-details type="password" v-model="user.token" />
</v-col>
</v-row>
</v-card>
</v-card>
</template>
<script lang="ts">
import Data from '@/store/modules/data'
export default {
props: ['data'],
computed: {
outTags() {
return [...Data().outbounds?.map((o: any) => o.tag) ?? [], ...Data().endpoints?.map((e: any) => e.tag) ?? []]
},
},
methods: {
addUser() {
if (!this.$props.data.users) this.$props.data.users = []
this.$props.data.users.push({ name: '', token: '' })
},
delUser(i: number | string) {
this.$props.data.users?.splice(Number(i), 1)
},
},
}
</script>
+100
View File
@@ -0,0 +1,100 @@
<template>
<v-card style="padding: 8px;" rounded="xl" class="border">
<v-card-subtitle>Shadowsocks API
<v-chip color="primary" density="compact" variant="elevated" @click="add_server"><v-icon icon="mdi-plus" /></v-chip>
</v-card-subtitle>
<v-row v-for="(server, index) in servers">
<v-col cols="auto" align-self="center" justify-self="center">
<v-icon @click="del_server(index)" color="error" icon="mdi-delete" />
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.path')"
hide-details
@input="update_key(index,$event.target.value)"
v-model="server.name">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-select
:label="$t('objects.inbound')"
hide-details
:items="ssTags"
@update:model-value="update_value(index,$event)"
v-model="server.value">
</v-select>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
type Server = {
name: string
value: string
}
export default {
props: ['data', 'ssTags'],
data() {
return {}
},
methods: {
add_server() {
this.servers = [...this.servers, {name: "/ss" + this.servers.length, value: this.ssTags[0] || ""}]
},
del_server(i:number) {
let h = this.servers
h.splice(i,1)
this.servers = h
},
update_key(i:number,k:string) {
let h = this.servers
h[i].name = k
this.servers = h
},
update_value(i:number,v:string) {
let h = this.servers
h[i].value = v
this.servers = h
},
},
computed: {
servers: {
get() :Server[] {
let servers: Server[] = []
const h = this.$props.data.servers
if (h) {
Object.keys(h).forEach(key => {
if (Array.isArray(h[key])){
h[key].forEach((v:string) => servers.push({ name: key, value: v }))
} else {
servers.push({ name: key, value: h[key] })
}
})
}
return servers
},
set(v:Server[]) {
if (v.length>0) {
let servers:any = {}
v.forEach((h:Server) => {
if (servers[h.name]) {
if (Array.isArray(servers[h.name])) {
servers[h.name].push(h.value)
} else {
servers[h.name] = [servers[h.name], h.value]
}
} else {
servers[h.name] = h.value
}
})
this.$props.data.servers = servers
} else {
this.$props.data.servers = undefined
}
}
}
},
}
</script>
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts" setup>
import { HumanReadable } from '@/plugins/utils'
import { computed } from 'vue'
const props = defineProps({
tilesData: <any>{},
type: String
})
const data = computed(() => {
const d = props.tilesData
if (!d.mem && !d.cpu) return { percent: 0, text: '-' }
switch (props.type) {
case 'g-cpu':
return { percent: d.cpu, text: Math.ceil(d.cpu) + "%" }
case 'g-mem':
return gaugeData(d.mem)
case 'g-dsk':
return gaugeData(d.dsk)
case 'g-swp':
return gaugeData(d.swp)
}
return { percent: 0, text: '-'}
})
const gaugeData = (d:any) :any => {
if (!d) return { percent: 0, text: '-' }
const curr = HumanReadable.sizeFormat(d.current,0).split(' ')
const total = HumanReadable.sizeFormat(d.total,0).split(' ')
if (curr[1] == total[1]) curr[1] = ''
return {
percent: Math.ceil(d.current*100/d.total),
text: curr[0] + "<sup>" + (curr[1]?? ' ') + "</sup>/" + total[0] + "<sup>" + (total[1]?? '') + "</sup>"
}
}
const cssTransformRotateValue = computed(() => {
const percentageAsFraction = data.value.percent / 100
const halfPercentage = percentageAsFraction / 2
return `${halfPercentage}turn`
})
const gaugeColor = computed(() => {
if (data.value.percent > 90) return 'error'
if (data.value.percent > 70) return 'warning'
return 'info'
})
</script>
<template>
<div class="gauge__outer">
<div class="gauge__inner">
<div
class="gauge__fill"
:style="{
transform: `rotate(${cssTransformRotateValue})`,
background: `rgb(var(--v-theme-${gaugeColor}))`
}">
</div>
<div class="gauge__cover"><span dir="ltr" v-html="data.text"></span></div>
</div>
</div>
</template>
<style scoped>
.gauge__outer {
width: 100%;
max-width: 250px;
}
.gauge__inner {
width: 100%;
height: 0;
padding-bottom: 50%;
background: rgb(var(--v-theme-surface));
position: relative;
border-top-left-radius: 100% 200%;
border-top-right-radius: 100% 200%;
overflow: hidden;
}
.gauge__fill {
position: absolute;
top: 100%;
left: 0;
width: inherit;
height: 100%;
background: rgb(var(--v-theme-primary));
transform-origin: center top;
transform: rotate(0turn);
transition: transform 0.2s ease-out;
}
.gauge__cover {
width: 75%;
height: 150%;
background: rgb(var(--v-theme-background));
position: absolute;
top: 25%;
left: 50%;
transform: translateX(-50%);
border-radius: 50%;
/* Text */
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 25%;
box-sizing: border-box;
font-family: 'Lexend', sans-serif;
font-weight: bold;
font-size: 32px;
}
sup {
font-size: 16px;
}
</style>
+213
View File
@@ -0,0 +1,213 @@
<template>
<Line v-if="loaded" :data="data" :options="<any>options" />
</template>
<script lang="ts">
import { ref } from 'vue'
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Filler,
} from 'chart.js'
import { HumanReadable } from '@/plugins/utils'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Filler
)
ChartJS.defaults.font.family = 'Vazirmatn'
export default {
components: {
Line
},
props: ['tilesData','type'],
data() {
return {
loaded: false,
labels: new Array(20).fill(''),
oldValues: <any>{net: {}, dio: {}},
options1: {
animation: false,
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
plugins: {
tooltip: {
enabled: false
},
legend: {
display: false,
}
},
scales: {
y: {
min: 0,
max: 100,
grid: {
color: '#777777',
},
beginAtZero: true,
ticks: {
beginAtZero: true,
steps: 10,
stepValue: 5,
max: 100
}
}
}
},
optionsNet: {
animation: false,
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
plugins: {
tooltip: {
enabled: false
},
legend: {
display: false,
}
},
scales: {
y: {
grid: {
color: '#777777',
},
beginAtZero: true,
ticks: {
callback: (label:any, index: number) => { return parseInt(label).toString() },
count: 10
}
}
}
},
data: ref(<any>{})
}
},
computed: {
options() {
switch (this.$props.type){
case "h-net":
this.optionsNet.scales.y.ticks.callback = (label:any, index: number) => {
return label == 0 ? "0" : HumanReadable.sizeFormat(label,0)
}
return this.optionsNet
case "hp-net":
this.optionsNet.scales.y.ticks.callback = (label:any, index: number) => {
return label == 0 ? "0" : HumanReadable.packetFormat(label,0)
}
return this.optionsNet
case "h-dio":
this.optionsNet.scales.y.ticks.callback = (label:any, index: number) => {
return label == 0 ? "0" : HumanReadable.sizeFormat(label,0)
}
return this.optionsNet
}
return this.options1
}
},
methods: {
updateData1(value1: number) {
const newData = <number[]>[]
if (this.data.datasets){
newData.push(...this.data.datasets[0].data,value1)
}
if (newData.length>20) newData.shift()
this.data = {
labels: this.labels,
datasets: [
{
label: '',
backgroundColor: 'rgba(255, 165, 0, 0.2)',
borderColor: 'rgba(255, 165, 0,0.8)',
fill: true,
data: newData
}
],
}
this.loaded = true
},
updateData2(value1: number, value2:number) {
const newData1 = <number[]>[]
const newData2 = <number[]>[]
if (this.data.datasets){
newData1.push(...this.data.datasets[0].data,value1)
newData2.push(...this.data.datasets[1].data,value2)
}
if (newData1.length>20) newData1.shift()
if (newData2.length>20) newData2.shift()
this.data = {
labels: this.labels,
datasets: [
{
label: '',
backgroundColor: 'rgba(255, 165, 0, 0.2)',
borderColor: 'rgba(255, 165, 0,0.8)',
fill: true,
data: newData1
},
{
label: '',
backgroundColor: 'rgba(0, 128, 0, 0.1)',
borderColor: 'rgba(0, 128, 0,0.8)',
fill: true,
data: newData2
}
],
}
this.loaded = true
}
},
watch: {
tilesData(v:any) {
switch (this.$props.type) {
case 'h-cpu':
this.updateData1(v.cpu)
break
case 'h-mem':
this.updateData1(v.mem.current*100/v.mem.total)
break
case 'h-net':
if (this.oldValues.net.sent) {
const downSpeed = (v.net.recv-this.oldValues.net.recv)/2 // Each 2 sec
const upSpeed = (v.net.sent-this.oldValues.net.sent)/2 // Each 2 sec
this.updateData2(upSpeed,downSpeed)
}
this.oldValues.net = v.net
break
case 'hp-net':
if (this.oldValues.net.psent) {
const downSpeed = (v.net.precv-this.oldValues.net.precv)/2 // Each 2 sec
const upSpeed = (v.net.psent-this.oldValues.net.psent)/2 // Each 2 sec
this.updateData2(upSpeed,downSpeed)
}
this.oldValues.net = v.net
break
case 'h-dio':
if (this.oldValues.dio.read) {
const downSpeed = (v.dio.read-this.oldValues.dio.read)/2 // Each 2 sec
const upSpeed = (v.dio.write-this.oldValues.dio.write)/2 // Each 2 sec
this.updateData2(upSpeed,downSpeed)
}
this.oldValues.dio = v.dio
break
}
}
}
}
</script>
+259
View File
@@ -0,0 +1,259 @@
<template>
<v-card subtitle="ACME" style="background-color: inherit;">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('enable')" v-model="enabled" hide-details></v-switch>
</v-col>
<v-col cols="12" md="8" v-if="enabled">
<v-text-field
:label="$t('rule.domain') + ' ' + $t('commaSeparated')"
hide-details
v-model="domains">
</v-text-field>
</v-col>
</v-row>
<template v-if="enabled">
<v-row>
<v-col cols="12" sm="6" md="4" v-if="optionDir">
<v-text-field
:label="$t('tls.acme.dataDir')"
hide-details
v-model="acme.data_directory">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionDefault">
<v-combobox
v-model="acme.default_server_name"
:items="acme.domain"
:label="$t('tls.acme.defaultDomain')"
hide-details
></v-combobox>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionEmail">
<v-text-field
:label="$t('email')"
hide-details
v-model="acme.email">
</v-text-field>
</v-col>
</v-row>
<v-row v-if="optionChallenge">
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('tls.acme.httpChallenge')" v-model="acme.disable_http_challenge" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('tls.acme.tlsChallenge')" v-model="acme.disable_tls_alpn_challenge" hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="optionPorts">
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('tls.acme.altHport')"
hide-details
type="number"
min=1
max="65532"
v-model.number="acme.alternative_http_port">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('tls.acme.altTport')"
hide-details
type="number"
min=1
max="65532"
v-model.number="acme.alternative_tls_port">
</v-text-field>
</v-col>
</v-row>
<v-row v-if="optionProvider">
<v-col cols="12" sm="6" md="4">
<v-select
v-model="caProvider"
:items="providerList"
:label="$t('tls.acme.caProvider')"
hide-details
></v-select>
</v-col>
<v-col cols="12" md="8" v-if="caProvider == ''">
<v-text-field
:label="$t('tls.acme.customCa')"
hide-details
v-model="acme.provider">
</v-text-field>
</v-col>
</v-row>
<v-row v-if="acme.external_account != undefined">
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Key ID"
hide-details
v-model="acme.external_account.key_id">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="MAC Key"
hide-details
v-model="acme.external_account.mac_key">
</v-text-field>
</v-col>
</v-row>
<v-row v-if="acme.dns01_challenge != undefined">
<v-col cols="12" sm="6" md="4">
<v-select
:label="$t('tls.acme.dns01Provider')"
hide-details
:items="dnsProviders.map(d => d.provider)"
@update:model-value="acme.dns01_challenge = { provider: $event }"
v-model="acme.dns01_challenge.provider">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4"
v-for="item in dnsProviders.filter(d => d.provider == acme.dns01_challenge?.provider)[0]?.params"
:key="item">
<v-text-field
:label="$t('tls.acme.dns01Params.' + item)"
hide-details
v-model="acme.dns01_challenge[item]">
</v-text-field>
</v-col>
</v-row>
<v-card-actions>
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('tls.acme.options') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionDir" color="primary" :label="$t('tls.acme.dataDir')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionDefault" color="primary" :label="$t('tls.acme.defaultDomain')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionEmail" color="primary" :label="$t('email')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionChallenge" color="primary" :label="$t('tls.acme.disableChallenges')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionPorts" color="primary" :label="$t('tls.acme.altPorts')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionProvider" color="primary" :label="$t('tls.acme.caProvider')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionExt" color="primary" :label="$t('tls.acme.extAcc')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionDns01" color="primary" :label="$t('tls.acme.dns01')" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</template>
</v-card>
</template>
<script lang="ts">
import { acme } from '@/types/tls'
export default {
props: ['tls'],
data() {
return {
menu: false,
providerList: [
{ title: "Let's Encrypt", value: "letsencrypt" },
{ title: "ZeroSSL", value: "zerossl" },
{ title: "Custom", value: "" }
],
dnsProviders: [
{ provider: "cloudflare", params: [ "api_token", "zone_token" ] },
{ provider: "alidns", params: [ "access_key_id", "access_key_secret", "region_id", "security_token" ] },
{ provider: "acmedns", params: [ "username", "password", "subdomain", "server_url" ] }
]
}
},
computed: {
acme() {
return <acme>this.$props.tls.acme
},
enabled: {
get() { return this.acme != undefined },
set(v: boolean) { this.$props.tls.acme = v ? { domain: [] } : undefined }
},
domains: {
get() { return this.acme?.domain ? this.acme.domain.join(',') : "" },
set(v: string) {
if(!v.endsWith(',')) {
this.acme.domain = v.length > 0 ? v.split(',') : []
}
}
},
caProvider: {
get() { return this.acme?.provider && ['letsencrypt','zerossl'].includes(this.acme.provider) ? this.acme?.provider : '' },
set(v: string) { this.acme.provider = ['letsencrypt','zerossl'].includes(v) ? v : 'https://' }
},
optionDir: {
get(): boolean { return this.acme?.data_directory != undefined },
set(v:boolean) { this.acme.data_directory = v ? '' : undefined }
},
optionDefault: {
get(): boolean { return this.acme?.default_server_name != undefined },
set(v:boolean) { this.acme.default_server_name = v ? this.domains.length>0 ? this.domains[0] : '' : undefined }
},
optionEmail: {
get(): boolean { return this.acme?.email != undefined },
set(v:boolean) { this.acme.email = v ? '' : undefined }
},
optionChallenge: {
get(): boolean { return this.acme?.disable_http_challenge != undefined || this.acme?.disable_tls_alpn_challenge != undefined },
set(v:boolean) {
if (v) {
this.acme.disable_http_challenge = false
this.acme.disable_tls_alpn_challenge = false
} else {
delete this.acme.disable_http_challenge
delete this.acme.disable_tls_alpn_challenge
}
}
},
optionPorts: {
get(): boolean { return this.acme?.alternative_http_port != undefined || this.acme?.alternative_tls_port != undefined },
set(v:boolean) {
if (v) {
this.acme.alternative_http_port = 80
this.acme.alternative_tls_port = 443
} else {
delete this.acme.alternative_http_port
delete this.acme.alternative_tls_port
}
}
},
optionProvider: {
get(): boolean { return this.acme?.provider != undefined },
set(v:boolean) { this.acme.provider = v ? 'letsencrypt' : undefined }
},
optionExt: {
get(): boolean { return this.acme?.external_account != undefined },
set(v:boolean) { this.acme.external_account = v ? { key_id: '', mac_key: '' } : undefined }
},
optionDns01: {
get(): boolean { return this.acme?.dns01_challenge != undefined },
set(v:boolean) {
if (v) {
this.acme.dns01_challenge = { provider: 'cloudflare' }
} else {
delete this.acme.dns01_challenge
}
}
},
}
}
</script>
+165
View File
@@ -0,0 +1,165 @@
<template>
<v-card subtitle="ECH" style="background-color: inherit;">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('enable')" v-model="enabled" hide-details></v-switch>
</v-col>
</v-row>
<template v-if="enabled">
<v-row>
<v-col cols="auto">
<v-btn-toggle v-model="useEchPath"
class="rounded-xl"
density="compact"
variant="outlined"
shaped
mandatory>
<v-btn
@click="delete ech.key"
>{{ $t('tls.usePath') }}</v-btn>
<v-btn
@click="delete ech.key_path"
>{{ $t('tls.useText') }}</v-btn>
</v-btn-toggle>
</v-col>
<v-spacer></v-spacer>
<v-col cols="auto">
<v-btn
variant="tonal"
density="compact"
icon="mdi-key-star"
@click="genECH"
:loading="loading">
<v-icon />
<v-tooltip activator="parent" location="top">
{{ $t('actions.generate') }}
</v-tooltip>
</v-btn>
</v-col>
</v-row>
<v-row v-if="useEchPath == 0">
<v-col cols="12">
<v-text-field
:label="$t('tls.keyPath')"
hide-details
v-model="ech.key_path">
</v-text-field>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12">
<v-textarea
:label="$t('tls.key')"
hide-details
v-model="echKeyText">
</v-textarea>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" v-if="oTls.ech">
<v-text-field
:label="$t('tls.queryServerName')"
hide-details
v-model="oTls.ech.query_server_name"
placeholder="ech.example.com">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
:label="$t('tls.cert')"
hide-details
v-model="echConfigText">
</v-textarea>
</v-col>
</v-row>
</template>
</v-card>
</template>
<script lang="ts">
import { i18n } from '@/locales'
import HttpUtils from '@/plugins/httputil'
import { ech } from '@/types/tls'
import { push } from 'notivue'
export default {
props: ['iTls','oTls'],
data() {
return {
useEchPath: this.$props.iTls?.ech?.key? 1:0,
loading: false,
}
},
methods: {
async genECH(){
this.loading = true
const msg = await HttpUtils.get('api/keypairs', {
k: "ech",
o: this.iTls.server_name?? "''"
})
this.loading = false
if (msg.success && this.iTls.ech && this.oTls.ech) {
this.iTls.ech.key_path=undefined
this.useEchPath = 1
if (msg.obj.length>0){
let config = <string[]>[]
let key = <string[]>[]
let isConfig = false
let isKey = false
msg.obj.forEach((line:string) => {
if (line === "-----BEGIN ECH CONFIGS-----") {
isConfig = true
isKey = false
config.push(line)
} else if (line === "-----END ECH CONFIGS-----") {
isConfig = false
config.push(line)
} else if (line === "-----BEGIN ECH KEYS-----") {
isKey = true
isConfig = false
key.push(line)
} else if (line === "-----END ECH KEYS-----") {
isKey = false
key.push(line)
} else if (isConfig) {
config.push(line)
} else if (isKey) {
key.push(line)
}
})
this.iTls.ech.key = key?? undefined
this.oTls.ech.config = config?? undefined
} else {
push.error({
message: i18n.global.t('error') + ": " + msg.obj
})
}
}
},
},
computed: {
ech() {
return <ech>this.$props.iTls.ech
},
enabled: {
get() { return this.ech?.enabled?? false },
set(v: boolean) {
this.$props.iTls.ech = v ? { enabled: true } : undefined
this.$props.oTls.ech = v ? {} : undefined
}
},
echKeyText: {
get(): string { return this.ech?.key ? this.ech.key.join('\n') : '' },
set(newValue:string) { this.ech.key = newValue.split('\n') }
},
echConfigText: {
get(): string { return this.oTls.ech?.config ? this.oTls.ech.config.join('\n') : '' },
set(newValue:string) { this.oTls.ech.config = newValue.split('\n') }
},
}
}
</script>
+26
View File
@@ -0,0 +1,26 @@
<template>
<v-card :subtitle="$t('objects.tls')">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('template')"
:items="tlsItems"
v-model="inbound.tls_id">
</v-select>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import { i18n } from '@/locales'
export default {
props: ['inbound', 'tlsConfigs'],
computed: {
tlsItems(): any[] {
return [ { title: i18n.global.t('none'), value: 0 }, ...this.$props.tlsConfigs?.map((t:any) => { return { title: t.name, value: t.id } } )]
}
}
}
</script>
+381
View File
@@ -0,0 +1,381 @@
<template>
<v-card :subtitle="$t('objects.tls')">
<v-row v-if="tlsOptional">
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('tls.enable')" v-model="tlsEnable" hide-details></v-switch>
</v-col>
</v-row>
<template v-if="tls.enabled">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('tls.disableSni')" v-model="disable_sni" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('tls.insecure')" v-model="insecure" hide-details></v-switch>
</v-col>
</v-row>
<template v-if="optionCert">
<v-row>
<v-col cols="auto">
<v-btn-toggle v-model="usePath"
class="rounded-xl"
density="compact"
variant="outlined"
shaped
mandatory>
<v-btn
@click="tls.certificate=undefined; tls.certificate_path=''"
>{{ $t('tls.usePath') }}</v-btn>
<v-btn
@click="tls.certificate_path=undefined; tls.certificate=''"
>{{ $t('tls.useText') }}</v-btn>
</v-btn-toggle>
</v-col>
</v-row>
<v-row v-if="usePath == 0">
<v-col cols="12" sm="6">
<v-text-field
:label="$t('tls.certPath')"
hide-details
v-model="tls.certificate_path">
</v-text-field>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12" sm="6">
<v-textarea
:label="$t('tls.cert')"
hide-details
v-model="tls.certificate">
</v-textarea>
</v-col>
</v-row>
</template>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="tls.server_name != undefined">
<v-text-field
label="SNI"
hide-details
v-model="tls.server_name">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="tls.alpn">
<v-select
hide-details
label="ALPN"
multiple
:items="alpn"
v-model="tls.alpn">
</v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="tls.min_version">
<v-select
hide-details
:label="$t('tls.minVer')"
:items="tlsVersions"
v-model="tls.min_version">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="tls.max_version">
<v-select
hide-details
:label="$t('tls.maxVer')"
:items="tlsVersions"
v-model="tls.max_version">
</v-select>
</v-col>
</v-row>
<v-row v-if="tls.cipher_suites != undefined">
<v-col cols="12" md="8">
<v-select
hide-details
:label="$t('tls.cs')"
multiple
:items="cipher_suites"
v-model="tls.cipher_suites">
</v-select>
</v-col>
</v-row>
<v-row v-if="tls.utls != undefined">
<v-col cols="12" md="6">
<v-select
hide-details
label="Fingerprint"
:items="fingerprints"
v-model="tls.utls.fingerprint">
</v-select>
</v-col>
</v-row>
<v-row v-if="tls.reality != undefined">
<v-col cols="12" md="6">
<v-text-field
:label="$t('tls.pubKey')"
hide-details
v-model="tls.reality.public_key">
</v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-text-field
label="Short ID"
hide-details
v-model="tls.reality.short_id">
</v-text-field>
</v-col>
</v-row>
<template v-if="tls.ech != undefined">
<v-row>
<v-col class="v-card-subtitle">ECH</v-col>
</v-row>
<v-row>
<v-col cols="auto">
<v-btn-toggle v-model="useEchPath"
class="rounded-xl"
density="compact"
variant="outlined"
shaped
mandatory>
<v-btn
@click="delete tls.ech?.config"
>{{ $t('tls.usePath') }}</v-btn>
<v-btn
@click="delete tls.ech?.config_path"
>{{ $t('tls.useText') }}</v-btn>
</v-btn-toggle>
</v-col>
</v-row>
<v-row v-if="useEchPath == 0">
<v-col cols="12" sm="6">
<v-text-field
:label="$t('tls.certPath')"
hide-details
v-model="tls.ech.config_path">
</v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
:label="$t('tls.queryServerName')"
hide-details
v-model="tls.ech.query_server_name"
placeholder="ech.example.com">
</v-text-field>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12" sm="6">
<v-textarea
:label="$t('tls.cert')"
hide-details
v-model="echConfigText">
</v-textarea>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
:label="$t('tls.queryServerName')"
hide-details
v-model="tls.ech.query_server_name"
placeholder="ech.example.com">
</v-text-field>
</v-col>
</v-row>
</template>
<v-row v-if="tls.fragment != undefined">
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('tls.fragment')" v-model="tls.fragment" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="tls.fragment">
<v-switch color="primary" :label="$t('tls.recordFragment')" v-model="tls.record_fragment" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="tls.fragment">
<v-text-field
:label="$t('tls.fragmentDelay')"
hide-details
type="number"
min=0
:suffix="$t('date.ms')"
v-model.number="fragmentFallbackDelay">
</v-text-field>
</v-col>
</v-row>
</template>
<v-card-actions v-if="tls.enabled">
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('tls.options') }}</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionCert" color="primary" :label="$t('tls.cert')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionSNI" color="primary" label="SNI" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionALPN" color="primary" label="ALPN" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionMinV" color="primary" :label="$t('tls.minVer')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionMaxV" color="primary" :label="$t('tls.maxVer')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionCS" color="primary" :label="$t('tls.cs')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionFP" color="primary" label="UTLS" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionReality" color="primary" label="Reality" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionEch" color="primary" label="ECH" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionFragment" color="primary" :label="$t('tls.fragment')" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import { oTls, defaultOutTls } from '@/types/tls'
export default {
props: ['outbound'],
data() {
return {
menu: false,
usePath: this.$props.outbound?.tls?.certificate? 1:0,
useEchPath: this.$props.outbound?.tls.ech?.config? 1:0,
defaults: defaultOutTls,
alpn: [
{ title: "H3", value: 'h3' },
{ title: "H2", value: 'h2' },
{ title: "Http/1.1", value: 'http/1.1' },
],
tlsVersions: [ '1.0', '1.1', '1.2', '1.3' ],
cipher_suites: [
{ title: "RSA-AES128-CBC-SHA", value: "TLS_RSA_WITH_AES_128_CBC_SHA" },
{ title: "RSA-AES256-CBC-SHA", value: "TLS_RSA_WITH_AES_256_CBC_SHA" },
{ title: "RSA-AES128-GCM-SHA256", value: "TLS_RSA_WITH_AES_128_GCM_SHA256" },
{ title: "RSA-AES256-GCM-SHA384", value: "TLS_RSA_WITH_AES_256_GCM_SHA384" },
{ title: "AES128-GCM-SHA256", value: "TLS_AES_128_GCM_SHA256" },
{ title: "AES256-GCM-SHA384", value: "TLS_AES_256_GCM_SHA384" },
{ title: "CHACHA20-POLY1305-SHA256", value: "TLS_CHACHA20_POLY1305_SHA256" },
{ title: "ECDHE-ECDSA-AES128-CBC-SHA", value: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA" },
{ title: "ECDHE-ECDSA-AES256-CBC-SHA", value: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA" },
{ title: "ECDHE-RSA-AES128-CBC-SHA", value: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA" },
{ title: "ECDHE-RSA-AES256-CBC-SHA", value: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA" },
{ title: "ECDHE-ECDSA-AES128-GCM-SHA256", value: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" },
{ title: "ECDHE-ECDSA-AES256-GCM-SHA384", value: "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" },
{ title: "ECDHE-RSA-AES128-GCM-SHA256", value: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" },
{ title: "ECDHE-RSA-AES256-GCM-SHA384", value: "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" },
{ title: "ECDHE-ECDSA-CHACHA20-POLY1305-SHA256", value: "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256" },
{ title: "ECDHE-RSA-CHACHA20-POLY1305-SHA256", value: "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256" }
],
fingerprints: [
{ title: "Chrome", value: "chrome" },
{ title: "Firefox", value: "firefox" },
{ title: "Microsoft Edge", value: "edge" },
{ title: "Apple Safari", value: "safari" },
{ title: "360", value: "360" },
{ title: "QQ", value: "qq" },
{ title: "Apple IOS", value: "ios" },
{ title: "Android", value: "android" },
{ title: "Random", value: "random" },
{ title: "Randomized", value: "randomized" },
]
}
},
computed: {
tls(): oTls {
return <oTls> this.$props.outbound.tls
},
tlsEnable: {
get() { return Object.hasOwn(this.tls, 'enabled') ? this.tls.enabled : false },
set(newValue: boolean) { this.$props.outbound.tls = newValue ? { enabled: true } : { enabled: false } }
},
disable_sni: {
get() { return this.tls.disable_sni ?? false },
set(newValue: boolean) { this.$props.outbound.tls.disable_sni = newValue ? true : undefined }
},
insecure: {
get() { return this.tls.insecure ?? false },
set(newValue: boolean) { this.$props.outbound.tls.insecure = newValue ? true : undefined }
},
tlsOptional(): boolean {
return !['hysteria','hysteria2','tuic','shadowtls', 'anytls', 'naive'].includes(this.$props.outbound.type)
},
echConfigText: {
get(): string { return this.tls.ech?.config ? this.tls.ech.config.join('\n') : '' },
set(newValue:string) { if (this.tls.ech) this.tls.ech.config = newValue.split('\n') }
},
optionCert: {
get(): boolean { return this.tls.certificate != undefined || this.tls.certificate_path != undefined },
set(v:boolean) {
this.usePath = 0
if (v) {
this.$props.outbound.tls.certificate_path = ""
} else {
delete this.$props.outbound.tls.certificate_path
delete this.$props.outbound.tls.certificate
}
}
},
optionSNI: {
get(): boolean { return this.tls.server_name != undefined },
set(v:boolean) { this.$props.outbound.tls.server_name = v ? '' : undefined }
},
optionALPN: {
get(): boolean { return this.tls.alpn != undefined },
set(v:boolean) { this.$props.outbound.tls.alpn = v ? defaultOutTls.alpn : undefined }
},
optionMinV: {
get(): boolean { return this.tls.min_version != undefined },
set(v:boolean) { this.$props.outbound.tls.min_version = v ? defaultOutTls.min_version : undefined }
},
optionMaxV: {
get(): boolean { return this.tls.max_version != undefined },
set(v:boolean) { this.$props.outbound.tls.max_version = v ? defaultOutTls.max_version : undefined }
},
optionCS: {
get(): boolean { return this.tls.cipher_suites != undefined },
set(v:boolean) { this.$props.outbound.tls.cipher_suites = v ? defaultOutTls.cipher_suites : undefined }
},
optionFP: {
get(): boolean { return this.tls.utls != undefined },
set(v:boolean) { this.$props.outbound.tls.utls = v ? defaultOutTls.utls : undefined }
},
optionReality: {
get(): boolean { return this.tls.reality != undefined },
set(v:boolean) { this.$props.outbound.tls.reality = v ? defaultOutTls.reality : undefined }
},
optionEch: {
get(): boolean { return this.tls.ech != undefined },
set(v:boolean) { this.$props.outbound.tls.ech = v ? defaultOutTls.ech : undefined }
},
optionFragment: {
get(): boolean { return this.tls.fragment != undefined },
set(v:boolean) {
if (v) {
this.$props.outbound.tls.fragment = false
} else {
delete this.$props.outbound.tls.fragment
delete this.$props.outbound.tls.fragment_fallback_delay
delete this.$props.outbound.tls.record_fragment
}
}
},
fragmentFallbackDelay: {
get(): number { return parseInt(this.tls.fragment_fallback_delay?.replace('ms','')?? '500')?? 500 },
set(v:number) { this.$props.outbound.tls.fragment_fallback_delay = v>0 ? `${v}ms` : undefined }
}
}
}
</script>
@@ -0,0 +1,82 @@
<template>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.hosts')"
hide-details
v-model="hosts">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.path')"
hide-details
v-model="transport.path">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-select
:label="$t('transport.httpMethod')"
hide-details
clearable
@click:clear="delete transport.method"
:items="methodList"
v-model="transport.method">
</v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.idleTimeout')"
hide-details
type="number"
suffix="s"
min="1"
v-model.number="idle_timeout">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.pingTimeout')"
hide-details
type="number"
suffix="s"
min="1"
v-model.number="ping_timeout">
</v-text-field>
</v-col>
</v-row>
<Headers :data="transport" />
</template>
<script lang="ts">
import { HTTP } from '../../types/transport'
import Headers from '../Headers.vue'
export default {
props: ['transport'],
data() {
return {
methodList: ['POST', 'GET', 'PUT', 'PATCH', 'DELETE']
}
},
computed: {
Http(): HTTP {
return <HTTP> this.$props.transport?? {}
},
hosts: {
get() { return this.Http.host ? this.Http.host.join(',') : '' },
set(newValue:string) { this.$props.transport.host = newValue.length>0 ? newValue.split(',') : [] }
},
idle_timeout: {
get() { return this.Http.idle_timeout ? parseInt(this.Http.idle_timeout.replace('s','')) : '' },
set(newValue:number) { this.$props.transport.idle_timeout = newValue ? newValue + 's' : '' }
},
ping_timeout: {
get() { return this.Http.ping_timeout ? parseInt(this.Http.ping_timeout.replace('s','')) : '' },
set(newValue:number) { this.$props.transport.ping_timeout = newValue ? newValue + 's' : '' }
}
},
components: { Headers }
}
</script>
@@ -0,0 +1,31 @@
<template>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.hosts')"
hide-details
v-model="transport.host">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.path')"
hide-details
v-model="transport.path">
</v-text-field>
</v-col>
</v-row>
<Headers :data="transport" />
</template>
<script lang="ts">
import Headers from '../Headers.vue'
export default {
props: ['transport'],
data() {
return {
}
},
components: { Headers }
}
</script>
@@ -0,0 +1,69 @@
<template>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.path')"
hide-details
v-model="transport.path">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.host')"
hide-details
v-model="host">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Max Early Data"
hide-details
type="number"
min="0"
v-model.number="max_early_data">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Early Data Header Name"
hide-details
v-model="transport.early_data_header_name">
</v-text-field>
</v-col>
</v-row>
<Headers :data="transport" />
</template>
<script lang="ts">
import { WebSocket } from '../../types/transport'
import Headers from '../Headers.vue'
export default {
props: ['transport'],
data() {
return {
}
},
computed: {
WS(): WebSocket {
return <WebSocket> this.$props.transport
},
max_early_data: {
get() { return this.WS.max_early_data ? this.WS.max_early_data : '' },
set(newValue:number) { this.$props.transport.max_early_data = newValue != 0 ? newValue : undefined }
},
host: {
get() { return this.WS.headers?.Host ? this.WS.headers.Host : '' },
set(newValue:string) {
this.$props.transport.headers = newValue != "" ? { Host: newValue } : undefined
}
},
},
mounted() {
this.WS.early_data_header_name ??= 'Sec-WebSocket-Protocol'
this.WS.path ??= '/'
},
components: { Headers }
}
</script>
@@ -0,0 +1,65 @@
<template>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.grpcServiceName')"
hide-details
v-model="transport.service_name">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch
color="primary"
v-model="transport.permit_without_stream"
:label="$t('transport.grpcPws')"
hide-details>
</v-switch>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.idleTimeout')"
hide-details
type="number"
suffix="s"
min="1"
v-model.number="idle_timeout">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.pingTimeout')"
hide-details
type="number"
suffix="s"
min="1"
v-model.number="ping_timeout">
</v-text-field>
</v-col>
</v-row>
</template>
<script lang="ts">
import { gRPC } from '../../types/transport'
export default {
props: ['transport'],
data() {
return {
}
},
computed: {
GRPC(): gRPC {
return <gRPC> this.$props.transport?? {}
},
idle_timeout: {
get() { return this.GRPC.idle_timeout ? parseInt(this.GRPC.idle_timeout.replace('s','')) : '' },
set(newValue:number) { this.$props.transport.idle_timeout = newValue ? newValue + 's' : '' }
},
ping_timeout: {
get() { return this.GRPC.ping_timeout ? parseInt(this.GRPC.ping_timeout.replace('s','')) : '' },
set(newValue:number) { this.$props.transport.ping_timeout = newValue ? newValue + 's' : '' }
}
}
}
</script>
+78
View File
@@ -0,0 +1,78 @@
<template>
<v-app-bar :elevation="5">
<v-icon v-if="isMobile" icon="mdi-menu" @click="$emit('toggleDrawer')" />
<span v-else style="width: 24px"></span>
<v-app-bar-title :text="$t(<string>route.name)" class="align-center text-center " />
<v-menu>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props">
<v-icon>mdi-translate</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="lang in languages"
:key="lang.value"
@click="changeLocale(lang.value)"
:active="isActiveLocale(lang.value)"
>
<v-list-item-title>{{ lang.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props">
<v-icon>mdi-theme-light-dark</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="th in themes"
:key="th.value"
@click="changeTheme(th.value)"
:prepend-icon="th.icon"
:active="isActiveTheme(th.value)"
>
<v-list-item-title>{{ $t(`theme.${th.value}`) }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
</template>
<script lang="ts" setup>
import { useLocale, useTheme } from 'vuetify'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { languages } from '@/locales'
defineProps(['isMobile'])
const route = useRoute()
const { locale: i18nLocale } = useI18n()
const vuetifyLocale = useLocale()
const theme = useTheme()
const changeLocale = (l: string) => {
i18nLocale.value = l
vuetifyLocale.current.value = l
localStorage.setItem('locale', l)
window.location.reload()
}
const isActiveLocale = (l: string) => i18nLocale.value === l
const themes = [
{ value: 'light', icon: 'mdi-white-balance-sunny' },
{ value: 'dark', icon: 'mdi-moon-waning-crescent' },
{ value: 'system', icon: 'mdi-laptop' },
]
const changeTheme = (th: string) => {
theme.change(th)
localStorage.setItem('theme', th)
}
const isActiveTheme = (th: string) => {
const current = localStorage.getItem('theme') ?? 'system'
return current == th
}
</script>
+38
View File
@@ -0,0 +1,38 @@
<template>
<v-app style="overflow: auto;">
<drawer :isMobile="isMobile" :displayDrawer="displayDrawer" @toggleDrawer="toggleDrawer" />
<default-bar :isMobile="isMobile" @toggleDrawer="toggleDrawer" />
<default-view />
</v-app>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import DefaultBar from './AppBar.vue'
import Drawer from './Drawer.vue'
import DefaultView from './View.vue'
import { useDisplay } from 'vuetify'
const { smAndDown } = useDisplay()
const displayDrawer = ref(false)
const toggleDrawer = () => {
displayDrawer.value = !displayDrawer.value
}
const isMobile = computed( ():boolean =>{
displayDrawer.value = !smAndDown.value
return smAndDown.value
})
</script>
<style>
.v-card-subtitle {
text-align: center;
border-bottom: 1px solid gray;
min-height: 20px;
}
.v-switch.v-input {
padding-inline-start: .6rem;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<template>
<v-navigation-drawer
v-model="showDrawer"
:temporary="isMobile"
:expand-on-hover="!isMobile"
:rail="!isMobile"
:permanent="!isMobile"
@click="isMobile ? $emit('toggleDrawer') : null"
>
<v-list-item
height="63"
prepend-avatar="@/assets/logo.svg"
title="S-UI"
>
<template v-slot:append v-if="isMobile">
<v-icon icon="mdi-close" />
</template>
</v-list-item>
<v-divider></v-divider>
<v-list density="compact" nav>
<v-list-item link
v-for="item in menu"
:key="item.title"
:to="item.path"
:active="router.currentRoute.value.path == item.path">
<template v-slot:prepend>
<v-icon :icon="item.icon"></v-icon>
</template>
<v-list-item-title v-text="$t(item.title)"></v-list-item-title>
</v-list-item>
</v-list>
<template v-slot:append>
<v-list-item prepend-icon="mdi-logout" :title="$t('menu.logout')" @click="Logout"></v-list-item>
</template>
</v-navigation-drawer>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import router from '@/router'
import { logout } from '@/plugins/httputil'
const props = defineProps(['isMobile','displayDrawer'])
const showDrawer = computed((): boolean => {
return props.displayDrawer
})
const menu = [
{ title: 'pages.home', icon: 'mdi-home', path: '/' },
{ title: 'pages.inbounds', icon: 'mdi-cloud-download', path: '/inbounds' },
{ title: 'pages.clients', icon: 'mdi-account-multiple', path: '/clients' },
{ title: 'pages.outbounds', icon: 'mdi-cloud-upload', path: '/outbounds' },
{ title: 'pages.endpoints', icon: 'mdi-cloud-tags', path: '/endpoints' },
{ title: 'pages.services', icon: 'mdi-server', path: '/services' },
{ title: 'pages.tls', icon: 'mdi-certificate', path: '/tls' },
{ title: 'pages.basics', icon: 'mdi-application-cog', path: '/basics' },
{ title: 'pages.rules', icon: 'mdi-routes', path: '/rules' },
{ title: 'pages.dns', icon: 'mdi-dns', path: '/dns' },
{ title: 'pages.admins', icon: 'mdi-account-tie', path: '/admins' },
{ title: 'pages.settings', icon: 'mdi-cog', path: '/settings' },
]
const Logout = async () => {
logout()
}
</script>
+14
View File
@@ -0,0 +1,14 @@
<template>
<v-main>
<router-view />
</v-main>
</template>
<script lang="ts" setup>
</script>
<style>
.v-main {
margin: 10px;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="400">
<v-card class="rounded-lg">
<v-card-title>
{{ $t('admin.changeCred') + " " + user.username }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col>
<v-text-field v-model="newData.oldPass" :label="$t('admin.oldPass')" :rules="passwordRules" type="password" required></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field v-model="newData.newUsername" :label="$t('admin.newUname')" :rules="usernameRules" required></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field v-model="newData.newPass" :label="$t('admin.newPass')" :rules="passwordRules" type="password" required></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="primary"
variant="tonal"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { i18n } from '@/locales'
export default {
props: ['visible', 'user'],
data() {
return {
newData: {
id: 0,
oldPass: "",
newUsername: "",
newPass: ""
},
usernameRules: [
(value: string) => {
if (value?.length > 0) return true
return i18n.global.t('login.unRules')
},
],
passwordRules: [
(value: string) => {
if (value?.length > 0) return true
return i18n.global.t('login.pwRules')
},
]
}
},
methods: {
resetData() {
this.newData.id = this.$props.user.id
this.newData.oldPass = ""
this.newData.newUsername = ""
this.newData.newPass = ""
},
closeModal() {
this.resetData() // reset
this.$emit('close')
},
saveChanges() {
if (this.newData.oldPass == '' || this.newData.newUsername == '' || this.newData.newPass == '') return
this.$emit('save', this.newData)
},
},
watch: {
visible(newValue) {
if (newValue) {
this.resetData()
}
},
},
}
</script>
+99
View File
@@ -0,0 +1,99 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="90%" max-width="500">
<v-card class="rounded-lg">
<v-card-title>
<v-row>
<v-col>{{ $t('main.backup.title') }}</v-col>
<v-spacer></v-spacer>
<v-col cols="auto">
<v-icon icon="mdi-close" @click="control.visible = false" />
</v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col cols="auto">
<v-checkbox v-model="exclude" :label="$t('main.backup.exclStats')" value="stats" hide-details></v-checkbox>
</v-col>
<v-col cols="auto">
<v-checkbox v-model="exclude" :label="$t('main.backup.exclChanges')" value="changes" hide-details></v-checkbox>
</v-col>
</v-row>
<v-row>
<v-col cols="auto" align-self="center">
<v-btn color="primary" @click="backup()" hide-details>{{ $t('main.backup.backup') }}</v-btn>
</v-col>
<v-spacer></v-spacer>
<v-col cols="auto" align-self="center">
<v-btn color="primary" @click="restore()" hide-details>{{ $t('main.backup.restore') }}</v-btn>
</v-col>
</v-row>
<v-row>
<v-divider></v-divider>
<v-col cols="auto" align-self="center">
<v-btn color="primary" @click="config()" hide-details>{{ $t('main.backup.sbConfig') }}</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import HttpUtils from '@/plugins/httputil'
export default {
props: ['control', 'visible'],
data() {
return {
exclude: ["stats", "changes"],
}
},
methods: {
backup() {
const excludeOption = this.exclude.length>0 ? '?exclude=' +this.exclude.join(',') : ''
window.location.href = 'api/getdb' + excludeOption
},
config() {
window.location.href = 'api/singbox-config'
},
restore() {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.db'
fileInput.addEventListener('change', async (event: Event) => {
const inputElement = event.target as HTMLInputElement
const dbFile = inputElement.files ? inputElement.files[0] : null
if (dbFile) {
const formData = new FormData()
formData.append('db', dbFile)
this.control.visible = false
const uploadMsg = await HttpUtils.post('api/importdb', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
if (uploadMsg.success) {
await new Promise(resolve => setTimeout(resolve, 1000))
location.reload()
}
}
})
fileInput.click()
}
},
watch: {
visible(v) {
if (v) {
this.exclude = ["stats", "changes"]
}
},
},
}
</script>
+145
View File
@@ -0,0 +1,145 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="90%" max-width="800" :loading="loading">
<v-card class="rounded-lg">
<v-card-title>
<v-row>
<v-col>{{ $t('admin.changes') }}</v-col>
<v-spacer></v-spacer>
<v-col cols="auto"><v-icon icon="mdi-close-box" @click="$emit('close')" /></v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col cols="12" sm="4" md="3">
<v-select
hide-details
:label="$t('admin.actor')"
:items="['', 'DepleteJob', ...admins]"
v-model="user"
@update:model-value="loadData">
</v-select>
</v-col>
<v-col cols="12" sm="4" md="3">
<v-select
hide-details
:label="$t('admin.key')"
:items="['', 'inbounds', 'outbounds', 'clients', 'route', 'tls', 'experimental']"
v-model="key"
@update:model-value="loadData">
</v-select>
</v-col>
<v-col cols="6" sm="4" md="3">
<v-select
hide-details
:label="$t('count')"
:items="[10,20,30,50,100]"
v-model.number="chngCount"
@update:model-value="loadData">
</v-select>
</v-col>
<v-col cols="auto" align="center" justify="center">
<v-btn
icon="mdi-refresh"
variant="tonal"
:loading="loading"
@click="loadData">
<v-icon />
</v-btn>
</v-col>
</v-row>
<v-data-table
:headers="changesHeaders"
:items="changes"
item-value="id"
density="compact"
show-expand
items-per-page="10"
>
<template v-slot:item.dateTime="{ value }">
<v-chip variant="text" dir="ltr" density="compact">
{{ dateFormatted(value) }}
</v-chip>
</template>
<template v-slot:item.action="{ value }">
<v-chip density="compact">
{{ $t('actions.' + value) }}
</v-chip>
</template>
<template v-slot:expanded-row="{ columns, item }">
<tr>
<td :colspan="columns.length">
<v-card dir="ltr" v-if="item.index>0">Index: {{ item.index }}</v-card>
<v-card style="background-color: background" dir="ltr"><pre>{{ item.obj }}</pre></v-card>
</td>
</tr>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { i18n } from '@/locales'
import HttpUtils from '@/plugins/httputil'
export default {
props: ['admins', 'actor', 'visible'],
data() {
return {
loading: false,
changes: <any[]>[],
user: '',
key: '',
chngCount: 10,
expanded: [],
changesHeaders: [
{ title: 'ID', key: 'id' },
{ title: i18n.global.t('admin.date') + '-' + i18n.global.t('admin.time'), key: 'dateTime' },
{ title: i18n.global.t('admin.actor'), key: 'Actor' },
{ title: i18n.global.t('admin.key'), key: 'key' },
{ title: i18n.global.t('admin.action'), key: 'action' },
],
}
},
methods: {
async loadData() {
this.loading = true
const data = await HttpUtils.get('api/changes',{ a: this.user, k: this.key, c: this.chngCount })
if (data.success) {
this.changes = data.obj?? []
this.loading = false
}
},
dateFormatted(dt: number): string {
const date = new Date(dt*1000)
return date.toLocaleString(this.locale)
},
},
computed: {
locale() {
const l = i18n.global.locale.value
switch (l) {
case "zhHans":
return "zh-cn"
case "zhHant":
return "zh-tw"
default:
return l
}
},
},
watch: {
visible(newValue) {
this.changes = []
this.user = this.$props.actor
this.key = ''
this.chngCount = 10
if (newValue) {
this.loadData()
}
},
},
}
</script>
+370
View File
@@ -0,0 +1,370 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg" :loading="loading">
<v-card-title>
{{ $t('actions.' + title) + " " + $t('objects.client') }}
</v-card-title>
<v-divider></v-divider>
<v-skeleton-loader
class="mx-auto border"
width="95%"
type="card, text, divider, list-item-two-line"
v-if="loading"
></v-skeleton-loader>
<v-card-text style="padding: 0 16px; overflow-y: scroll;">
<v-container style="padding: 0;" :hidden="loading">
<v-tabs
v-model="tab"
align-tabs="center"
>
<v-tab value="t1">{{ $t('client.basics') }}</v-tab>
<v-tab value="t2">{{ $t('client.config') }}</v-tab>
<v-tab value="t3">{{ $t('client.links') }}</v-tab>
</v-tabs>
<v-window v-model="tab">
<v-window-item value="t1">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="client.enable" :label="$t('enable')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-combobox v-model="client.group" :items="groups" :label="$t('client.group')" hide-details></v-combobox>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="client.name" :label="$t('client.name')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="client.desc" :label="$t('client.desc')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model.number="Volume" type="number" min="0" :label="$t('stats.volume')" suffix="GiB" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="!(client.delayStart && !client.autoReset)">
<DatePick :expiry="expDate" @submit="setDate" />
</v-col>
<v-col cols="12" sm="6" md="4" v-if="client.autoReset || client.delayStart">
<v-text-field v-model.number="resetDays" type="number" min="1" :label="$t('client.resetDays')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary"
:disabled="client.up+client.down>0"
v-model="delayStart"
:label="$t('client.delayStart')" hide-details>
</v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="autoReset" :label="$t('client.autoReset')" hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="id > 0">
<v-col cols="12" sm="6" md="4" class="d-flex flex-column">
<div class="d-flex justify-space-between align-center">
<div>
{{ $t('stats.usage') }}: {{ total }}<sup dir="ltr" v-if="percent>0">({{ percent }}%)</sup>
</div>
<v-btn density="compact" variant="text" icon="mdi-restore" @click="resetUsage">
<v-tooltip activator="parent" location="top">
{{ $t('reset') }}
</v-tooltip>
<v-icon />
</v-btn>
</div>
<v-progress-linear
v-model="percent"
:color="percentColor"
v-if="client.volume>0"
bottom
>
</v-progress-linear>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-icon icon="mdi-upload" color="orange" /><span class="text-orange">{{ up }}</span>
/
<v-icon icon="mdi-download" color="success" /><span class="text-success">{{ down }}</span>
</v-col>
</v-row>
<v-row v-if="id >0 && client.autoReset">
<v-col cols="12" sm="6" md="4">
<div class="text-medium-emphasis">{{ $t('client.nextReset') }}</div>
<div dir="ltr">{{ nextResetFormatted }}</div>
</v-col>
<v-col cols="12" sm="6" md="4">
<div class="text-medium-emphasis">{{ $t('main.stats.totalUsage') }}</div>
<div>
<v-icon icon="mdi-upload" color="orange" /><span class="text-orange">{{ totalUp }}</span>
/
<v-icon icon="mdi-download" color="success" /><span class="text-success">{{ totalDown }}</span>
</div>
</v-col>
</v-row>
<v-row>
<v-col>
<v-select
v-model="clientInbounds"
:items="inboundTags"
:label="$t('client.inboundTags')"
clearable
multiple
chips
hide-details>
<template v-slot:append>
<v-icon @click="setAllInbounds" icon="mdi-set-all" v-tooltip:top="$t('all')" />
</template>
</v-select>
</v-col>
</v-row>
</v-window-item>
<v-window-item value="t2">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-btn variant="tonal" @click="shuffle()">{{ $t('reset') + ' - ' + $t('all') }}<v-icon icon="mdi-refresh" /></v-btn>
</v-col>
</v-row>
<v-row v-for="key in Object.keys(clientConfig)">
<v-col cols="12" md="3" align="end" align-self="center">
{{ key }}
<v-icon @click="shuffle(key)" icon="mdi-refresh" v-tooltip:top="$t('reset')" />
</v-col>
<v-col>
<v-text-field
v-if="clientConfig[key].password != undefined"
label="Password"
v-model="clientConfig[key].password"
hide-details>
</v-text-field>
<v-text-field
v-if="clientConfig[key].uuid != undefined"
label="UUID"
v-model="clientConfig[key].uuid"
hide-details>
</v-text-field>
<v-text-field
v-if="key == 'vless'"
label="Flow"
v-model="clientConfig[key].flow"
hide-details>
</v-text-field>
<v-text-field
v-if="key == 'hysteria'"
label="Auth"
v-model="clientConfig[key].auth_str"
hide-details>
</v-text-field>
</v-col>
</v-row>
</v-window-item>
<v-window-item value="t3">
<v-row v-for="(lnk, index) in links">
<v-col cols="auto">{{ index + 1 }}</v-col>
<v-col style="direction: ltr; overflow-y: hidden;">{{ lnk.uri }}</v-col>
</v-row>
<v-row>
<v-col>
<v-btn color="primary" @click="extLinks.push({ type: 'external', uri: ''})">{{ $t('actions.add') }} {{ $t('client.external') }}</v-btn>
</v-col>
</v-row>
<v-row v-for="(lnk, index) in extLinks">
<v-col>
<v-text-field
dir="ltr"
:label="$t('client.external') + ' ' + (index+1)"
append-icon="mdi-delete"
@click:append="extLinks.splice(index,1)"
placeholder="<protocol>://<data>"
v-model="lnk.uri" />
</v-col>
</v-row>
<v-row>
<v-col>
<v-btn color="primary" @click="subLinks.push({ type: 'sub', uri: ''})">{{ $t('actions.add') }} {{ $t('client.sub') }}</v-btn>
</v-col>
</v-row>
<v-row v-for="(lnk, index) in subLinks">
<v-col>
<v-text-field
dir="ltr"
:label="$t('client.sub') + ' ' + (index+1)"
append-icon="mdi-delete"
@click:append="subLinks.splice(index,1)"
placeholder="http[s]://<domain>[:]<port>/<path>"
v-model="lnk.uri" />
</v-col>
</v-row>
</v-window-item>
</v-window>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="primary"
variant="tonal"
:loading="loading"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { createClient, randomConfigs, updateConfigs, Link, shuffleConfigs } from '@/types/clients'
import DatePick from '@/components/DateTime.vue'
import { HumanReadable } from '@/plugins/utils'
import Data from '@/store/modules/data'
import { locale } from '@/locales'
export default {
props: ['visible', 'id', 'inboundTags', 'groups'],
emits: ['close'],
data() {
return {
client: createClient(),
title: "add",
loading: false,
tab: "t1",
clientConfig: <any>[],
links: <Link[]>[],
extLinks: <Link[]>[],
subLinks: <Link[]>[],
}
},
methods: {
async updateData(id: number) {
if (id > 0) {
this.loading = true
const newData = await Data().loadClients(id)
this.client = createClient(newData)
this.title = "edit"
this.clientConfig = this.client.config
this.loading = false
}
else {
this.client = createClient()
this.title = "add"
this.clientConfig = randomConfigs('client')
}
this.links = this.client.links?.filter(l => l.type == 'local')?? []
this.extLinks = this.client.links?.filter(l => l.type == 'external')?? []
this.subLinks = this.client.links?.filter(l => l.type == 'sub')?? []
this.tab = "t1"
this.loading = false
},
closeModal() {
this.updateData(0) // reset
this.$emit('close')
},
async saveChanges() {
if (!this.$props.visible) return
// check duplicate name
const isDuplicateName = Data().checkClientName(this.$props.id, this.client.name)
if (isDuplicateName) return
// check if delayStart is true and autoReset is false, set expiry to 0
if (this.client.delayStart && !this.client.autoReset) this.client.expiry = 0
// save data
this.loading = true
this.client.config = updateConfigs(this.clientConfig, this.client.name)
this.client.links = [
...this.extLinks.filter(l => l.uri != ''),
...this.subLinks.filter(l => l.uri != '')]
const success = await Data().save("clients", this.$props.id == 0 ? "new" : "edit", this.client)
if (success) this.closeModal()
this.loading = false
},
setDate(newDate:number){
this.client.expiry = newDate
},
setAllInbounds(){
this.client.inbounds = this.inboundTags.map((i:any) => i.value).sort()
},
shuffle(k?:string) {
shuffleConfigs(this.clientConfig, k)
},
resetUsage(){
this.client.totalUp = (this.client.totalUp ?? 0) + this.client.up
this.client.totalDown = (this.client.totalDown ?? 0) + this.client.down
this.client.up = 0
this.client.down = 0
}
},
computed: {
clientInbounds: {
get() { return this.client.inbounds.length>0 ? this.client.inbounds.sort() : [] },
set(v:number[]) { this.client.inbounds = v.length == 0 ? [] : v.sort() }
},
expDate: {
get() { return this.client.expiry},
set(v:any) { this.client.expiry = v }
},
Volume: {
get() { return this.client.volume == 0 ? 0 : (this.client.volume / (1024 ** 3)) },
set(v:number) { this.client.volume = v > 0 ? v*(1024 ** 3) : 0 }
},
delayStart: {
get() { return this.client.delayStart?? false },
set(v:boolean) {
this.client.delayStart = v
this.client.resetDays = v ? 1 : 0
if (v && !this.autoReset) this.client.expiry = 0
}
},
autoReset: {
get() { return this.client.autoReset?? false },
set(v:boolean) {
this.client.autoReset = v
this.client.resetDays = v ? 1 : 0
if (!v) this.client.nextReset = 0
}
},
resetDays: {
get() { return this.client.resetDays?? 1 },
set(v:number|null) {
if (!v) v = 1
if (this.client.nextReset && this.client.nextReset > 0) {
this.client.nextReset += (v-(this.client.resetDays?? 0))*24*60*60
}
this.client.resetDays = v
}
},
up() :string { return HumanReadable.sizeFormat(this.client.up) },
down() :string { return HumanReadable.sizeFormat(this.client.down) },
total() :string { return HumanReadable.sizeFormat(this.client.down + this.client.up) },
totalUp() :string { return HumanReadable.sizeFormat((this.client.totalUp ?? 0) + this.client.up) },
totalDown() :string { return HumanReadable.sizeFormat((this.client.totalDown ?? 0) + this.client.down) },
nextResetFormatted() :string {
const ts = this.client.nextReset?? 0
if (ts == 0) return '-'
const date = new Date(ts*1000)
return date.toLocaleString(locale)
},
percent() :number { return this.client.volume>0 ? Math.round((this.client.up + this.client.down) *100 / this.client.volume) : 0 },
percentColor() :string { return (this.client.up+this.client.down) >= this.client.volume ? 'error' : this.percent>90 ? 'warning' : 'success' },
},
watch: {
visible(newValue) {
if (newValue) {
this.updateData(this.$props.id)
}
},
},
components: { DatePick },
}
</script>
@@ -0,0 +1,228 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
{{ $t('actions.addbulk') }}
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px; overflow-y: scroll;">
<v-container style="padding: 0;">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model.number="count" type="number" min="1" max="100" :label="$t('count')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="8">
<v-combobox
chips
multiple
v-model="bulkData.name"
:items="patterns"
:label="$t('client.name')"
hide-details>
</v-combobox>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="8">
<v-combobox
chips
multiple
v-model="bulkData.desc"
:items="patterns"
:label="$t('client.desc')"
hide-details>
</v-combobox>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-combobox v-model="bulkData.group" :items="groups" :label="$t('client.group')" hide-details></v-combobox>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model.number="bulkData.Volume" type="number" min="0" :label="$t('stats.volume')" suffix="GiB" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="!(bulkData.delayStart && !bulkData.autoReset)">
<DatePick :expiry="bulkData.expiry" @submit="setDate" />
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary"
v-model="bulkData.delayStart"
:label="$t('client.delayStart')" hide-details>
</v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="bulkData.autoReset" :label="$t('client.autoReset')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="bulkData.autoReset || bulkData.delayStart">
<v-text-field v-model.number="bulkData.resetDays" type="number" min="1" :label="$t('client.resetDays')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<v-select
v-model="bulkData.clientInbounds"
:items="inboundTags"
:label="$t('client.inboundTags')"
multiple
chips
hide-details
>
<template v-slot:append>
<v-icon @click="setAllInbounds" icon="mdi-set-all" v-tooltip:top="$t('all')" />
</template>
</v-select>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="primary"
variant="tonal"
:loading="loading"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import DatePick from '@/components/DateTime.vue'
import { push } from 'notivue'
import RandomUtil from '@/plugins/randomUtil'
import { Client, createClient, randomConfigs } from '@/types/clients'
import { i18n } from '@/locales'
import Data from '@/store/modules/data'
export default {
props: ['visible', 'inboundTags', 'groups'],
emits: ['close'],
data() {
return {
count: 1,
clients: <Client[]>[],
bulkData: {
name: <any[]>[],
desc: <any[]>[],
group: '',
clientInbounds: [],
expiry: 0,
Volume: 0,
delayStart: false,
autoReset: false,
resetDays: 0,
},
patterns: [
{ title: i18n.global.t("bulk.random"), value: "random" },
{ title: i18n.global.t("bulk.order"), value: "order" },
],
loading: false,
}
},
methods: {
resetData() {
this.count = 1,
this.clients = [],
this.bulkData = {
name: [this.patterns[1], "-", this.patterns[0]],
desc: [],
group: '',
clientInbounds: [],
expiry: 0,
Volume: 0,
delayStart: false,
autoReset: false,
resetDays: 0,
}
},
closeModal() {
this.$emit('close')
},
async saveChanges() {
if (!this.$props.visible) return
if (this.bulkData.name.findIndex(n => typeof(n) == 'object') == -1) {
push.error(i18n.global.t('error.dplData'))
return
}
this.clients = []
this.loading = true
for(let i=0;i<this.count;i++){
const name = this.genByPattern(this.bulkData.name, i)
this.clients.push(createClient({
enable: true,
name: name,
config: randomConfigs(name),
inbounds: this.bulkData.clientInbounds.length > 0 ? this.bulkData.clientInbounds.sort() : [],
links: [],
volume: this.bulkData.Volume*(1024 ** 3),
expiry: (this.bulkData.delayStart && !this.bulkData.autoReset) ? 0 : this.bulkData.expiry,
up: 0,
down: 0,
desc: this.genByPattern(this.bulkData.desc, i),
group: this.bulkData.group,
delayStart: this.bulkData.delayStart,
autoReset: this.bulkData.autoReset,
resetDays: this.bulkData.resetDays,
}))
}
// Check duplicate names
const isDuplicateName = Data().checkBulkClientNames(this.clients.map(c => c.name))
if (isDuplicateName) return
const success = await Data().save("clients", "addbulk", this.clients)
if (success) this.closeModal()
this.loading = false
},
genByPattern(pattern: any[], order :number){
if (pattern.length == 0) return RandomUtil.randomSeq(8)
let result = ''
pattern.forEach(p => {
switch(typeof p){
case 'object':
switch(p.value){
case "random":
result += RandomUtil.randomSeq(8)
break
case "order":
result += order+1
}
break
default:
result += p
}
})
return result
},
setDate(v:number){
this.bulkData.expiry = v
},
setAllInbounds(){
this.bulkData.clientInbounds = this.inboundTags.map((i:any) => i.value).sort()
}
},
computed: {},
watch: {
visible(newValue) {
if (newValue) {
this.resetData()
}
},
},
components: { DatePick },
}
</script>
@@ -0,0 +1,196 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
{{ $t('actions.editbulk') }}
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px; overflow-y: scroll;">
<v-container style="padding: 0;">
<v-card :subtitle="$t('actions.action')" class="mb-4">
<v-card-text>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
v-model="actionMode"
:items="actionModes"
:label="$t('actions.action')"
hide-details
@update:model-value="onActionChange"
></v-select>
</v-col>
</v-row>
<v-row v-if="actionMode === 'change_limits'">
<v-col cols="12" sm="6" md="4">
<v-text-field
v-model.number="editData.addDays"
type="number"
:label="$t('bulk.addDays')"
:suffix="$t('date.d')"
hide-details
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
v-model.number="editData.addVolume"
type="number"
:label="$t('bulk.addVolume')"
:suffix="$t('stats.GB')"
hide-details
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch
v-model="editData.enable"
:label="$t('enable')"
color="primary"
hide-details
></v-switch>
</v-col>
</v-row>
<v-row v-if="actionMode === 'add_inbounds' || actionMode === 'remove_inbounds'">
<v-col cols="12" sm="8">
<v-select
v-model="editData.inboundTags"
:items="inboundTags"
:label="$t('client.inboundTags')"
multiple
chips
hide-details
></v-select>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Section two: Clients (like init users in Inbound modal) -->
<Users :clients="clients" :data="selectedClients" />
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="primary"
variant="tonal"
:loading="loading"
:disabled="selectedClients.values.length == 0"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import Users from '@/components/Users.vue'
import { i18n } from '@/locales'
import Data from '@/store/modules/data';
import { Client } from '@/types/clients';
export default {
props: ['visible', 'clients', 'inboundTags'],
emits: ['close'],
components: { Users },
data() {
return {
loading: false,
actionMode: 'change_limits',
actionModes: [
{ title: i18n.global.t('bulk.changeLimits'), value: 'change_limits' },
{ title: i18n.global.t('bulk.addInbounds'), value: 'add_inbounds' },
{ title: i18n.global.t('bulk.removeInbounds'), value: 'remove_inbounds' },
{ title: i18n.global.t('actions.delbulk'), value: 'delete_bulk' },
],
editData: {
enable: true,
addDays: 0,
addVolume: 0,
inboundTags: [] as number[],
},
selectedClients: {
model: 'none',
values: [] as any[],
},
}
},
methods: {
onActionChange() {
this.editData.inboundTags = []
},
closeModal() {
this.$emit('close')
},
getTargetClients(): Client[] {
const clients = this.clients ?? []
switch (this.selectedClients.model) {
case 'all':
return clients
case 'group':
return clients
.filter((c: any) => this.selectedClients.values.includes(c.group))
case 'client':
return clients.filter((c: any) => this.selectedClients.values.includes(c.id))
default:
return []
}
},
async saveChanges() {
this.loading = true
const targetClients = this.getTargetClients()
switch (this.actionMode) {
case 'change_limits':
targetClients.forEach((c: Client) => {
if (this.editData.addVolume != 0 && c.volume > 0)
c.volume += this.editData.addVolume*(1024 ** 3)
if (this.editData.addDays != 0 && c.expiry > 0)
c.expiry += this.editData.addDays*(24 * 60 * 60)
if (this.editData.enable)
c.enable = (c.volume == 0 || c.up + c.down < c.volume) && (c.expiry == 0 || c.expiry > Date.now()/1000)
})
break
case 'add_inbounds':
targetClients.forEach((c: Client) => {
this.editData.inboundTags.forEach((t: number) => {
if (!c.inbounds.includes(t)) {
c.inbounds.push(t)
}
})
c.inbounds = c.inbounds.sort()
})
break
case 'remove_inbounds':
targetClients.forEach((c: Client) => {
c.inbounds = c.inbounds.filter((i: number) => !this.editData.inboundTags.includes(i))
})
break
case 'delete_bulk':
const success = await Data().save("clients", "delbulk", targetClients.map((c: Client) => c.id))
if (success) this.closeModal()
this.loading = false
return
}
const success = await Data().save("clients", 'editbulk', targetClients)
if (success) this.closeModal()
this.loading = false
},
},
watch: {
visible(newVal) {
if (newVal) {
this.actionMode = 'change_limits'
this.editData = { enable: true, addDays: 0, addVolume: 0, inboundTags: [] }
this.selectedClients = { model: 'none', values: [] }
}
},
},
}
</script>
+210
View File
@@ -0,0 +1,210 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
{{ $t('actions.' + title) + " " + $t('objects.dnsserver') }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
v-model="dnsServer.type"
:items="dnsTypes"
:label="$t('type')"
@update:modelValue="changeType"
hide-details
/>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="dnsServer.tag" :label="$t('objects.tag')" hide-details />
</v-col>
</v-row>
<v-row v-if="HasServer.includes(dnsServer.type)">
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="dnsServer.server" :label="$t('in.addr')" hide-details />
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model.number="dnsServer.server_port" type="number" min="0" :label="$t('in.port')" hide-details />
</v-col>
</v-row>
<v-row v-if="HasHeaders.includes(dnsServer.type)">
<v-col cols="12" sm="8">
<v-text-field v-model="dnsServer.path" :label="$t('transport.path')" hide-details />
</v-col>
</v-row>
<DialVue :dial="dnsServer" v-if="!WithoutDial.includes(dnsServer.type)" />
<oTlsVue :outbound="dnsServer" v-if="HasTls.includes(dnsServer.type)" />
<Headers :data="dnsServer" v-if="HasHeaders.includes(dnsServer.type)" />
<template v-if="dnsServer.type == 'hosts'">
<v-row>
<v-col cols="12" sm="6">
<v-text-field v-model="hostsPath" :label="$t('transport.path') + $t('commaSeparated')" hide-details />
</v-col>
</v-row>
<v-card>
<v-card-subtitle>Predefined
<v-chip color="primary" density="compact" variant="elevated" @click="addHostsPredefined"><v-icon icon="mdi-plus" /></v-chip>
</v-card-subtitle>
<v-row v-for="(pd, index) in hostsPredefined">
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="pd.name" :label="$t('setting.domain')" @input="update_pds_key(index,$event.target.value)" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="pd.value"
:label="$t('types.tun.addr') + $t('commaSeparated')"
@input="update_pds_value(index,$event.target.value)"
hide-details>
<template v-slot:append>
<v-icon @click="delHostsPredefined(index)" color="error" icon="mdi-delete" />
</template>
</v-text-field>
</v-col>
</v-row>
</v-card>
</template>
<v-row v-if="dnsServer.type == 'local'">
<v-col cols="12" sm="6" md="4">
<v-switch v-model="dnsServer.prefer_go" color="primary" :label="$t('dns.local.preferGo')" hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="dnsServer.type == 'dhcp'">
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="dnsServer.interface" :label="$t('types.tun.ifName')" hide-details />
</v-col>
</v-row>
<v-row v-if="dnsServer.type == 'fakeip'">
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="dnsServer.inet4_range" :label="$t('dns.rule.inet4Range')" hide-details />
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="dnsServer.inet6_range" :label="$t('dns.rule.inet6Range')" hide-details />
</v-col>
</v-row>
<v-row v-if="dnsServer.type == 'tailscale' || dnsServer.type == 'resolved'">
<v-col cols="12" sm="6" md="4" v-if="dnsServer.type == 'tailscale'">
<v-select v-model="dnsServer.endpoint" :label="$t('objects.endpoint')" :items="tsTags" hide-details />
</v-col>
<v-col cols="12" sm="6" md="4" v-if="dnsServer.type == 'resolved'">
<v-select v-model="dnsServer.service" :label="$t('objects.service')" :items="rslvdTags" hide-details />
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="dnsServer.accept_default_resolvers" :label="$t('dns.rule.acceptDefault')" hide-details></v-switch>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="outlined" @click="close">{{ $t('actions.close') }}</v-btn>
<v-btn color="blue-darken-1" variant="tonal" @click="save">{{ $t('actions.save') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import DialVue from '@/components/Dial.vue'
import oTlsVue from '@/components/tls/OutTLS.vue'
import Headers from '@/components/Headers.vue'
import RandomUtil from '@/plugins/randomUtil'
import { DnsTypes, createDnsServer } from '@/types/dns'
export default {
props: ['visible', 'data', 'index', 'tsTags', 'rslvdTags'],
emits: ['close', 'save'],
data() {
return {
title: "add",
dnsServer: createDnsServer("local",{tag: "dns-" + RandomUtil.randomSeq(3)}),
dnsTypes: Object.keys(DnsTypes).map((key,index) => ({title: key, value: Object.values(DnsTypes)[index]})),
HasServer: [DnsTypes.TCP, DnsTypes.UDP, DnsTypes.TLS, DnsTypes.QUIC, DnsTypes.HTTPS, DnsTypes.HTTP3],
HasHeaders: [DnsTypes.HTTPS, DnsTypes.HTTP3],
HasTls: [DnsTypes.TLS, DnsTypes.QUIC, DnsTypes.HTTPS, DnsTypes.HTTP3],
WithoutDial: [DnsTypes.Hosts, DnsTypes.Tailscale, DnsTypes.FakeIP, DnsTypes.Resolved],
}
},
methods: {
updateData() {
if (this.$props.index != -1) {
this.dnsServer = JSON.parse(this.$props.data)
this.title = 'edit'
}
else {
this.dnsServer = createDnsServer("local",{tag: "dns-" + RandomUtil.randomSeq(3)})
this.title = 'add'
}
},
changeType(dnsType: string) {
this.dnsServer = createDnsServer(dnsType,{tag: this.dnsServer.tag})
},
close() {
this.$emit('close')
},
save() {
this.$emit('save', this.dnsServer)
},
addHostsPredefined() {
const newPredefined = { name:'localhost', value: '127.0.0.1,::1' }
this.hostsPredefined = [...this.hostsPredefined, newPredefined]
},
delHostsPredefined(i:number) {
let pds = this.hostsPredefined
pds.splice(i,1)
this.hostsPredefined = pds
},
update_pds_key(i:number,k:string) {
let pds = this.hostsPredefined
pds[i].name = k
this.hostsPredefined = pds
},
update_pds_value(i:number,v:string) {
let pds = this.hostsPredefined
pds[i].value = v
this.hostsPredefined = pds
},
},
computed:{
hostsPath: {
get() { return this.dnsServer.path },
set(v: string) {
this.dnsServer.path = v.length > 0 ? v.split(',').map((item: string) => item.trim()) : undefined
}
},
hostsPredefined: {
get() :any[] {
let pds :any[] = []
const h = this.dnsServer.predefined
if (h) {
Object.keys(h).forEach(key => {
if (Array.isArray(h[key])){
pds.push({ name: key, value: h[key].join(',') })
} else {
pds.push({ name: key, value: h[key] })
}
})
}
return pds
},
set(v: any[]) {
if (v.length>0) {
let pds:any = {}
v.forEach((pd:any) => {
pds[pd.name] = pd.value.split(',').map((item: string) => item.trim())
})
this.dnsServer.predefined = pds
} else {
this.dnsServer.predefined = undefined
}
}
},
},
watch: {
visible(v) {
if (v) {
this.updateData()
}
},
},
components: { DialVue, oTlsVue, Headers }
}
</script>
+313
View File
@@ -0,0 +1,313 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
{{ $t('actions.' + title) + " " + $t('objects.dnsrule') }}
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px;">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="logical" :label="$t('rule.logical')" hide-details></v-switch>
</v-col>
<v-spacer></v-spacer>
<v-col cols="auto" v-if="logical" justify="center" align="center">
<v-btn color="primary" @click="ruleData.rules.push(<dnsRule>{})" hide-details>{{ $t('actions.add') + " " + $t('objects.rule') }}</v-btn>
</v-col>
</v-row>
<v-card style="background-color: inherit; margin-bottom: 5px;" v-for="(r, index) in ruleData.rules" v-if="ruleData.type == 'logical'">
<v-card-subtitle>{{ $t('objects.rule') + ' ' + (Number(index)+1) }}
<v-icon @click="ruleData.rules.splice(index,1)" icon="mdi-delete" v-if="ruleData.rules.length>1" />
</v-card-subtitle>
<v-card-text style="padding: 0;">
<RuleOptions
:rule="r"
:clients="clients"
:inTags="inTags"
:ruleSets="ruleSets" />
</v-card-text>
</v-card>
<RuleOptions
v-else
:rule="ruleData.rules[0]"
:clients="clients"
:inTags="inTags"
:ruleSets="ruleSets" />
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
v-model="ruleData.action"
:items="actions"
:label="$t('dns.rule.action.title')"
hide-details
></v-select>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="logical">
<v-combobox
v-model="ruleData.mode"
:items="['and', 'or']"
:label="$t('rule.mode')"
hide-details
></v-combobox>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="ruleData.invert" :label="$t('rule.invert')" hide-details></v-switch>
</v-col>
</v-row>
<v-card :subtitle="$t('dns.rule.action.route')" v-if="['route', 'route-options'].includes(ruleData.action)">
<v-row v-if="ruleData.action == 'route'">
<v-col cols="12" sm="6" md="4">
<v-select
v-model="ruleData.server"
:items="serverTags"
:label="$t('dns.server')"
hide-details
></v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-select
v-model="ruleData.strategy"
:items="strategies"
:label="$t('rule.strategy')"
clearable
@click:clear="delete ruleData.strategy"
hide-details>
</v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="ruleData.disable_cache" :label="$t('dns.disableCache')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model.number="ruleData.rewrite_ttl" type="number" min="0" :label="$t('dns.rule.action.rewriteTtl')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="ruleData.client_subnet" :label="$t('dns.rule.action.clientSubnet')" hide-details></v-text-field>
</v-col>
</v-row>
</v-card>
<v-card :subtitle="$t('dns.rule.action.reject')" v-if="ruleData.action == 'reject'">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
v-model="ruleData.method"
:items="[{ title: 'Default', value: 'default' },{ title: 'Drop', value: 'drop'}]"
:label="$t('rule.method')"
clearable
@click:clear="delete ruleData.method"
hide-details>
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="ruleData.no_drop" :label="$t('rule.noDrop')" hide-details></v-switch>
</v-col>
</v-row>
</v-card>
<v-card :subtitle="$t('dns.rule.action.predefined')" v-if="ruleData.action == 'predefined'">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
v-model="ruleData.rcode"
:items="predefinedRcode"
:label="$t('dns.rule.action.rcode')"
clearable
@click:clear="delete ruleData.rcode"
hide-details>
</v-select>
</v-col>
</v-row>
<v-row v-if="ruleData.rcode == 'NOERROR'">
<v-col cols="12" sm="8">
<v-text-field v-model="answer" :label="$t('dns.rule.action.answer') + ' ' + $t('commaSeparated')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="8">
<v-text-field v-model="ns" :label="$t('dns.rule.action.ns') + ' ' + $t('commaSeparated')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="8">
<v-text-field v-model="extra" :label="$t('dns.rule.action.extra') + ' ' + $t('commaSeparated')" hide-details></v-text-field>
</v-col>
</v-row>
</v-card>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="primary"
variant="tonal"
:loading="loading"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { logicalDnsRule, dnsRule, actionDnsRuleKeys } from '@/types/dns'
import RuleOptions from '@/components/DnsRule.vue'
import { i18n } from '@/locales'
export default {
props: ['visible', 'data', 'index', 'clients', 'inTags', 'serverTags', 'ruleSets'],
emits: ['close', 'save'],
data() {
return {
title: 'add',
loading: false,
ruleData: <any>{
type: 'logical',
mode: 'and',
rules: <dnsRule[]>[{}],
invert: false,
action: 'route',
server: 'local',
},
actions: [
{ title: i18n.global.t('dns.rule.action.route'), value: 'route'},
{ title: i18n.global.t('dns.rule.action.routeOptions'), value: 'route-options'},
{ title: i18n.global.t('dns.rule.action.reject'), value: 'reject'},
{ title: i18n.global.t('dns.rule.action.predefined'), value: 'predefined'},
],
strategies: [
{ title: 'Prefer IPv4', value: 'prefer_ipv4' },
{ title: 'Prefer IPv6', value: 'prefer_ipv6' },
{ title: 'IPv4 Only', value: 'ipv4_only' },
{ title: 'IPv6 Only', value: 'ipv6_only' },
],
predefinedRcode: [
{ title: i18n.global.t('dns.rule.action.rcodes.noError'), value: 'NOERROR' },
{ title: i18n.global.t('dns.rule.action.rcodes.formerr'), value: 'FORMERR' },
{ title: i18n.global.t('dns.rule.action.rcodes.servFail'), value: 'SERVFAIL' },
{ title: i18n.global.t('dns.rule.action.rcodes.nxDomain'), value: 'NXDOMAIN' },
{ title: i18n.global.t('dns.rule.action.rcodes.notImp'), value: 'NOTIMP' },
{ title: i18n.global.t('dns.rule.action.rcodes.refused'), value: 'REFUSED' },
],
}
},
methods: {
updateData() {
if (this.$props.index != -1) {
const newData = JSON.parse(this.$props.data)
if (newData.type) {
this.ruleData = newData
} else {
this.ruleData = {
type: 'simple',
mode: 'and',
rules: <dnsRule[]>[{}],
}
Object.keys(newData).forEach(key => {
if (actionDnsRuleKeys.includes(key)) {
this.ruleData[key] = newData[key]
} else {
this.ruleData.rules[0][key] = newData[key]
}
})
}
this.title = 'edit'
}
else {
this.ruleData = <logicalDnsRule>{
type: 'simple',
mode: 'and',
rules: <dnsRule[]>[{}],
invert: false,
action: 'route',
server: this.$props.serverTags[0]?? 'local',
}
this.title = 'add'
}
},
closeModal() {
this.$emit('close')
},
saveChanges() {
this.loading = true
let newRule = <any>{
action: this.ruleData.action,
invert: this.ruleData.invert? this.ruleData.invert : undefined,
}
// Filter action data
switch (newRule.action){
case 'route':
newRule.server = this.ruleData.server
newRule.strategy = this.ruleData.strategy?.length > 0 ? this.ruleData.strategy : undefined
newRule.disable_cache = this.ruleData.disable_cache? true : undefined
newRule.rewrite_ttl = this.ruleData.rewrite_ttl > 0 ? this.ruleData.rewrite_ttl : undefined
newRule.client_subnet = this.ruleData.client_subnet?.length > 0 ? this.ruleData.client_subnet : undefined
break
case 'route-options':
newRule.disable_cache = this.ruleData.disable_cache? true : undefined
newRule.rewrite_ttl = this.ruleData.rewrite_ttl > 0 ? this.ruleData.rewrite_ttl : undefined
newRule.client_subnet = this.ruleData.client_subnet?.length > 0 ? this.ruleData.client_subnet : undefined
break
case 'reject':
newRule.method = this.ruleData.method?.length > 0 ? this.ruleData.method : undefined
newRule.no_drop = this.ruleData.no_drop? true : undefined
break
case 'predefined':
newRule.rcode = this.ruleData.rcode?.length > 0 ? this.ruleData.rcode : undefined
if (this.ruleData.rcode == 'NOERROR') {
newRule.answer = this.ruleData.answer
newRule.ns = this.ruleData.ns
newRule.extra = this.ruleData.extra
}
break
}
// Add rules
if (this.ruleData.type == 'simple'){
newRule = { ...this.ruleData.rules[0], ...newRule }
} else {
newRule.type = 'logical'
newRule.mode = this.ruleData.mode
newRule.rules = this.ruleData.rules
}
this.$emit('save', newRule)
this.loading = false
},
deleteRule(index:number) {
this.ruleData.rules.splice(index,1)
}
},
computed: {
logical: {
get() { return this.ruleData.type == 'logical' },
set(v:boolean) {
this.ruleData.type = v? 'logical' : 'simple'
}
},
answer: {
get() { return this.ruleData.answer?.length > 0 ? this.ruleData.answer.join(',') : "" },
set(v:string) { this.ruleData.answer = v.length > 0 ? v.split(',') : undefined }
},
ns: {
get() { return this.ruleData.ns?.length > 0 ? this.ruleData.ns.join(',') : "" },
set(v:string) { this.ruleData.ns = v.length > 0 ? v.split(',') : undefined }
},
extra: {
get() { return this.ruleData.extra?.length > 0 ? this.ruleData.extra.join(',') : "" },
set(v:string) { this.ruleData.extra = v.length > 0 ? v.split(',') : undefined }
},
},
watch: {
visible(newValue) {
if (newValue) {
this.updateData()
}
},
},
components: { RuleOptions }
}
</script>
+227
View File
@@ -0,0 +1,227 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
{{ $t('actions.' + title) + " " + $t('objects.endpoint') }}
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px; overflow-y: scroll;">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:disabled="endpoint.id > 0"
:label="$t('type')"
:items="Object.keys(epTypes).map((key,index) => ({title: key, value: Object.values(epTypes)[index]}))"
v-model="endpoint.type"
@update:modelValue="changeType">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="endpoint.tag" :label="$t('objects.tag')" hide-details></v-text-field>
</v-col>
</v-row>
<Wireguard v-if="endpoint.type == epTypes.Wireguard"
:data="endpoint"
@getWgPubKey="getWgPubKey"
@newWgKey="newWgKey"
@addPeer="addWgPeer"
@delPeer="delWgPeer"
@refreshPeerKey="refreshWgPeerKey" />
<Warp v-if="endpoint.type == epTypes.Warp" :data="endpoint" />
<TailscaleVue v-if="endpoint.type == epTypes.Tailscale" :data="endpoint" />
<Dial :dial="endpoint" />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="primary"
variant="tonal"
:loading="loading"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { EpTypes, createEndpoint } from '@/types/endpoints'
import RandomUtil from '@/plugins/randomUtil'
import Dial from '@/components/Dial.vue'
import Wireguard from '@/components/protocols/Wireguard.vue'
import Warp from '@/components/protocols/Warp.vue'
import TailscaleVue from '@/components/protocols/Tailscale.vue'
import HttpUtils from '@/plugins/httputil'
import { push } from 'notivue'
import { i18n } from '@/locales'
import Data from '@/store/modules/data'
export default {
props: ['visible', 'data', 'id', 'tags'],
emits: ['close'],
data() {
return {
endpoint: createEndpoint("wireguard",{ "tag": "" }),
title: "add",
tab: "t1",
loading: false,
epTypes: EpTypes,
}
},
methods: {
async updateData(id: number) {
if (id > 0) {
const newData = JSON.parse(this.$props.data)
this.endpoint = createEndpoint(newData.type, newData)
this.title = "edit"
}
else {
this.endpoint.type = "wireguard"
this.endpoint.listen_port = RandomUtil.randomIntRange(10000, 60000)
this.changeType()
this.title = "add"
}
this.tab = "t1"
},
async changeType() {
// Tag change only in add endpoint
const tag = this.endpoint.type + "-" + RandomUtil.randomSeq(3)
// Use previous data
let prevConfig = {}
switch (this.endpoint.type) {
case EpTypes.Wireguard:
const wgKeys = (await this.genWgKey())
const randomIPoctet = RandomUtil.randomIntRange(1, 255)
prevConfig = {
tag: tag,
listen_port: this.endpoint.listen_port ?? RandomUtil.randomIntRange(10000, 60000),
address: ['10.0.0.'+ randomIPoctet.toString() +'/32','fe80::'+ randomIPoctet.toString(16) +'/128'],
private_key: wgKeys.private_key,
peers: [],
ext: {
public_key: wgKeys.public_key,
keys: []
}
}
break
case EpTypes.Warp:
prevConfig = {
tag: tag,
}
break
case EpTypes.Tailscale:
prevConfig = { tag: tag }
break
}
this.endpoint = createEndpoint(this.endpoint.type, prevConfig)
},
closeModal() {
this.updateData(0) // reset
this.$emit('close')
},
async saveChanges() {
if (!this.$props.visible) return
// check duplicate tag
const isDuplicatedTag = Data().checkTag("endpoint",this.endpoint.id, this.endpoint.tag)
if (isDuplicatedTag) return
// save data
this.loading = true
const success = await Data().save("endpoints", this.$props.id == 0 ? "new" : "edit", this.endpoint)
if (success) this.closeModal()
this.loading = false
},
async genWgKey(){
this.loading = true
const msg = await HttpUtils.get('api/keypairs', { k: "wireguard" })
this.loading = false
let result = { private_key: "", public_key: "" }
if (msg.success) {
msg.obj.forEach((line:string) => {
if (line.startsWith("PrivateKey")){
result.private_key = line.substring(12)
}
if (line.startsWith("PublicKey")){
result.public_key = line.substring(11)
}
})
} else {
push.error({
message: i18n.global.t('error') + ": " + msg.obj
})
}
return result
},
async newWgKey(){
this.loading = true
const newKeys = await this.genWgKey()
this.endpoint.private_key = newKeys.private_key
if (!this.endpoint.ext) this.endpoint.ext = {keys: []}
this.endpoint.ext.public_key = newKeys.public_key
this.loading = false
},
async getWgPubKey(private_key: string) {
if (!this.endpoint.ext) this.endpoint.ext = {keys: []}
this.loading = true
const msg = await HttpUtils.get('api/keypairs', { k: "wireguard", o: private_key })
if (msg.success) {
this.endpoint.ext.public_key = msg.obj[0]
}
this.loading = false
},
async addWgPeer(){
if (this.endpoint.type != EpTypes.Wireguard) return
this.loading = true
const newKeys = await this.genWgKey()
if (!this.endpoint.ext) this.endpoint.ext = {keys: []}
this.endpoint.ext.keys.push(newKeys)
this.endpoint.peers.push({
public_key: newKeys.public_key,
allowed_ips: [this.findFreeIP()]
})
this.loading = false
},
findFreeIP(): string{
const peerAllowedIPs = this.endpoint.peers.map((peer: any) => peer.allowed_ips).flat()
for (let i = 2; i < 255; i++) {
const newIP = '10.0.1.'+ i.toString() +'/32'
if (!peerAllowedIPs.includes(newIP)) return newIP
}
return '0.0.0.0/0'
},
delWgPeer(index: number){
if (this.endpoint.type != EpTypes.Wireguard) return
this.endpoint.ext.keys = this.endpoint.ext.keys.filter((key: any) => key.public_key != this.endpoint.peers[index].public_key)
this.endpoint.peers.splice(index, 1)
},
async refreshWgPeerKey(index: number) {
this.loading = true
const newKeys = await this.genWgKey()
if (!this.endpoint.ext) this.endpoint.ext = {keys: []}
const indexKeys = this.endpoint.ext.keys.findIndex((key: any) => key.public_key == this.endpoint.peers[index].public_key)
this.endpoint.ext.keys[indexKeys == -1 ? this.endpoint.ext.keys.length : indexKeys] = newKeys
this.endpoint.peers[index].public_key = newKeys.public_key
this.loading = false
},
},
watch: {
visible(v) {
if (v) {
this.updateData(this.$props.id)
}
},
},
components: { Dial, Wireguard, Warp, TailscaleVue }
}
</script>
+287
View File
@@ -0,0 +1,287 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800" @after-enter="updateData(id)">
<v-card class="rounded-lg" :loading="loading">
<v-card-title>
{{ $t('actions.' + title) + " " + $t('objects.inbound') }}
</v-card-title>
<v-divider></v-divider>
<v-skeleton-loader
class="mx-auto border"
width="95%"
type="card, text, divider, list-item-two-line"
v-if="loading"
></v-skeleton-loader>
<v-card-text style="padding: 0 16px; overflow-y: scroll;">
<v-container style="padding: 0;" :hidden="loading">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('type')"
:items="Object.keys(inTypes).map((key,index) => ({title: key, value: Object.values(inTypes)[index]}))"
v-model="inbound.type"
@update:modelValue="changeType">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="inbound.tag" :label="$t('objects.tag')" hide-details></v-text-field>
</v-col>
</v-row>
<v-tabs
v-if="HasInData.includes(inbound.type)"
v-model="side"
density="compact"
fixed-tabs
align-tabs="center"
>
<v-tab value="s">{{ $t('in.sSide') }}</v-tab>
<v-tab value="c">{{ $t('in.cSide') }}</v-tab>
</v-tabs>
<v-window v-model="side" style="margin-top: 10px;">
<v-window-item value="s">
<Listen :data="inbound" :inTags="inTags" v-if="inbound.type != inTypes.Tun" />
<Direct v-if="inbound.type == inTypes.Direct" :data="inbound" />
<Shadowsocks v-if="inbound.type == inTypes.Shadowsocks" direction="in" :data="inbound" />
<Hysteria v-if="inbound.type == inTypes.Hysteria" direction="in" :data="inbound" />
<Hysteria2 v-if="inbound.type == inTypes.Hysteria2" direction="in" :data="inbound" />
<Naive v-if="inbound.type == inTypes.Naive" direction="in" :data="inbound" />
<ShadowTls v-if="inbound.type == inTypes.ShadowTLS" direction="in" :data="inbound" />
<Tuic v-if="inbound.type == inTypes.TUIC" direction="in" :data="inbound" />
<Tun v-if="inbound.type == inTypes.Tun" :data="inbound" />
<AnyTls v-if="inbound.type == inTypes.AnyTls" :data="inbound" direction="in" />
<TProxy v-if="inbound.type == inTypes.TProxy" :inbound="inbound" />
<Transport v-if="Object.hasOwn(inbound,'transport')" :data="inbound" />
<Users v-if="hasUser" :clients="clients" :data="initUsers" />
<InTls v-if="HasTls.includes(inbound.type)" :inbound="inbound" :tlsConfigs="tlsConfigs" :tls_id="inbound.tls_id" />
<Multiplex v-if="MuxAvailable.includes(inbound.type)" direction="in" :data="inbound" />
</v-window-item>
<v-window-item value="c">
<OutJsonVue :inData="inbound" :type="inbound.type" />
<Multiplex v-if="Object.hasOwn(inbound,'multiplex')" direction="out" :data="inbound.out_json" />
<Dial v-if="inbound.out_json" :dial="inbound.out_json" mode="client" />
<v-card>
<v-card-text>
<v-card-subtitle>{{ $t('in.multiDomain') }}
<v-chip color="primary" density="compact" variant="elevated" @click="add_addr"><v-icon icon="mdi-plus" /></v-chip>
</v-card-subtitle>
<template v-for="addr,index in inbound.addrs">
{{ $t('in.addr') }} #{{ (index+1) }} <v-icon icon="mdi-delete" color="error" @click="inbound.addrs?.splice(index,1)" />
<v-divider></v-divider>
<AddrVue :addr="addr" :hasTls="HasTls.includes(inbound.type)" />
</template>
</v-card-text>
</v-card>
</v-window-item>
</v-window>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="primary"
variant="tonal"
:loading="loading"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { InTypes, createInbound, Addr, ShadowTLS } from '@/types/inbounds'
import RandomUtil from '@/plugins/randomUtil'
import Dial from '@/components/Dial.vue'
import Listen from '@/components/Listen.vue'
import Direct from '@/components/protocols/Direct.vue'
import Users from '@/components/Users.vue'
import Shadowsocks from '@/components/protocols/Shadowsocks.vue'
import Hysteria from '@/components/protocols/Hysteria.vue'
import Hysteria2 from '@/components/protocols/Hysteria2.vue'
import Naive from '@/components/protocols/Naive.vue'
import ShadowTls from '@/components/protocols/ShadowTls.vue'
import Tuic from '@/components/protocols/Tuic.vue'
import Tun from '@/components/protocols/Tun.vue'
import AnyTls from '@/components/protocols/AnyTls.vue'
import InTls from '@/components/tls/InTLS.vue'
import TProxy from '@/components/protocols/TProxy.vue'
import Multiplex from '@/components/Multiplex.vue'
import Transport from '@/components/Transport.vue'
import AddrVue from '@/components/Addr.vue'
import OutJsonVue from '@/components/OutJson.vue'
import Data from '@/store/modules/data'
export default {
props: ['visible', 'id', 'inTags', 'tlsConfigs'],
emits: ['close'],
data() {
return {
inbound: createInbound("direct",{ id:0, "tag": "" }),
title: "add",
loading: false,
side: "s",
inTypes: InTypes,
inboundWithUsers: ['mixed', 'socks', 'http', 'shadowsocks', 'vmess', 'trojan', 'naive', 'hysteria', 'shadowtls', 'tuic', 'hysteria2', 'vless', 'anytls'],
initUsers: {
model: 'none',
values: <any>[],
},
HasInData: [
InTypes.SOCKS,
InTypes.HTTP,
InTypes.Mixed,
InTypes.Shadowsocks,
InTypes.VMess,
InTypes.ShadowTLS,
InTypes.Trojan,
InTypes.Hysteria,
InTypes.VLESS,
InTypes.AnyTls,
InTypes.TUIC,
InTypes.Hysteria2,
InTypes.Naive,
],
HasTls: [
InTypes.HTTP,
InTypes.VMess,
InTypes.Trojan,
InTypes.Naive,
InTypes.Hysteria,
InTypes.TUIC,
InTypes.Hysteria2,
InTypes.VLESS,
InTypes.AnyTls,
],
MuxAvailable: [
InTypes.VLESS,
InTypes.VMess,
InTypes.Trojan,
InTypes.Shadowsocks,
],
OnlyTLS: [InTypes.Hysteria, InTypes.Hysteria2, InTypes.TUIC, InTypes.Naive, InTypes.AnyTls ],
}
},
methods: {
async loadData(id: number) {
this.loading = true
const inboundArray = await Data().loadInbounds([id])
this.inbound = inboundArray[0]
if (this.HasInData.includes(this.inbound.type) && this.inbound.out_json == null) {
this.inbound.out_json = {}
}
this.loading = false
},
updateData(id: number) {
if (id > 0) {
this.loadData(id)
this.title = "edit"
}
else {
const port = RandomUtil.randomIntRange(10000, 60000)
this.inbound = createInbound("direct",{ id: 0, tag: "direct-"+port ,listen: "::", listen_port: port })
if (this.HasInData.includes(this.inbound.type)){
this.inbound.addrs = []
this.inbound.out_json = {}
} else {
delete this.inbound.addrs
delete this.inbound.out_json
}
this.title = "add"
this.loading = false
}
this.side = "s"
this.initUsers = {
model: 'none',
values: [],
}
},
changeType() {
if (!this.inbound.listen_port) this.inbound.listen_port = RandomUtil.randomIntRange(10000, 60000)
// Tag change only in add inbound
const tag = this.$props.id > 0 ? this.inbound.tag : this.inbound.type + "-" + this.inbound.listen_port
// Use previous data
const prevConfig = { id: this.inbound.id, tag: tag, listen: this.inbound.listen?? "::", listen_port: this.inbound.listen_port }
this.inbound = createInbound(this.inbound.type, this.inbound.type != this.inTypes.Tun ? prevConfig : { tag: tag })
if (this.HasInData.includes(this.inbound.type)){
this.inbound.addrs = []
this.inbound.out_json = {}
} else {
delete this.inbound.addrs
delete this.inbound.out_json
}
this.side = "s"
},
add_addr() {
this.inbound.addrs?.push(<Addr>{ server: location.hostname, server_port: this.inbound.listen_port })
},
closeModal() {
this.updateData(0) // reset
this.$emit('close')
},
async saveChanges() {
if (!this.$props.visible) return
// check duplicate tag
const isDuplicatedTag = Data().checkTag("inbound", this.inbound.id, this.inbound.tag)
if (isDuplicatedTag) return
// save data
this.loading = true
let clientIds = []
if (this.hasUser) {
switch (this.initUsers.model) {
case 'all':
clientIds = this.clients.map((c:any) => c.id)
break
case 'group':
clientIds = this.clients.filter((c:any) => this.initUsers.values.includes(c.group)).map((c:any) => c.id)
break
case 'client':
clientIds = this.initUsers.values
}
}
const success = await Data().save("inbounds", this.$props.id == 0 ? "new" : "edit", this.inbound, clientIds)
if (success) this.closeModal()
this.loading = false
},
},
computed: {
validate() {
if (this.inbound == undefined) return false
if (this.inbound.tag == "") return false
if (this.inbound.listen_port > 65535 || this.inbound.listen_port < 1) return false
if (this.OnlyTLS.includes(this.inbound.type) && this.inbound.tls_id == 0) return false
return true
},
clients() {
return Data().clients?? []
},
hasUser() {
if (this.$props.id > 0) return false
if (!this.inboundWithUsers.includes(this.inbound.type)) return false
if (this.inbound.type == InTypes.ShadowTLS && (<ShadowTLS>this.inbound).version < 3 ) return false
if ((<any>this.inbound).managed) return false
return true
},
},
watch: {
visible(newValue) {
if (newValue) {
this.loading = true
}
},
},
components: {
Listen, InTls, Hysteria2, Naive, Direct, Shadowsocks,
Users, Hysteria, ShadowTls, TProxy, Multiplex, Tuic, Tun,
AnyTls, Transport, AddrVue, OutJsonVue, Dial
}
}
</script>
+90
View File
@@ -0,0 +1,90 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="90%" max-width="1200" :loading="loading">
<v-card class="rounded-lg">
<v-card-title>
<v-row>
<v-col>{{ $t('basic.log.title') }}</v-col>
<v-spacer></v-spacer>
<v-col cols="auto">
<v-icon icon="mdi-close" @click="control.visible = false" />
</v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('basic.log.level')"
:items="logLevels"
v-model="logLevel"
@update:model-value="loadData">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('count')"
:items="[10,20,30,50,100]"
v-model.number="logCount"
@update:model-value="loadData">
</v-select>
</v-col>
<v-col cols="auto" align="center" justify="center">
<v-btn
icon="mdi-refresh"
variant="tonal"
:loading="loading"
@click="loadData">
<v-icon />
</v-btn>
</v-col>
</v-row>
<v-card style="background-color: background" dir="ltr" v-html="lines.join('<br />')"></v-card>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import HttpUtils from '@/plugins/httputil'
export default {
props: ['control', 'visible'],
data() {
return {
loading: false,
lines: [],
logLevel: 'info',
logLevels: [
{ title: 'DEBUG', value: 'debug' },
{ title: 'INFO', value: 'info' },
{ title: 'WARNING', value: 'warning' },
{ title: 'ERROR', value: 'err' },
],
logCount: 10,
}
},
methods: {
async loadData() {
this.loading = true
const data = await HttpUtils.get('api/logs',{ c: this.logCount, l: this.logLevel })
if (data.success) {
this.lines = data.obj?? []
this.loading = false
}
}
},
watch: {
visible(v) {
this.lines = []
this.logLevel = 'info'
this.logCount = 10
if (v) {
this.loadData()
}
},
},
}
</script>
+212
View File
@@ -0,0 +1,212 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
{{ $t('actions.' + title) + " " + $t('objects.outbound') }}
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px; overflow-y: scroll;">
<v-container style="padding: 0;">
<v-tabs
v-model="tab"
align-tabs="center"
>
<v-tab value="t1">{{ $t('client.basics') }}</v-tab>
<v-tab value="t2">{{ $t('client.external') }}</v-tab>
</v-tabs>
<v-window v-model="tab">
<v-window-item value="t1">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('type')"
:items="Object.keys(outTypes).map((key,index) => ({title: key, value: Object.values(outTypes)[index]}))"
v-model="outbound.type"
@update:modelValue="changeType">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="outbound.tag" :label="$t('objects.tag')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row v-if="!NoServer.includes(outbound.type)">
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('out.addr')"
hide-details
v-model="outbound.server">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('out.port')"
type="number"
min="0"
hide-details
v-model.number="outbound.server_port">
</v-text-field>
</v-col>
</v-row>
<Socks v-if="outbound.type == outTypes.SOCKS" :data="outbound" />
<Http v-if="outbound.type == outTypes.HTTP" :data="outbound" />
<Shadowsocks v-if="outbound.type == outTypes.Shadowsocks" direction="out" :data="outbound" />
<Vmess v-if="outbound.type == outTypes.VMess" :data="outbound" />
<Trojan v-if="outbound.type == outTypes.Trojan" :data="outbound" />
<Hysteria v-if="outbound.type == outTypes.Hysteria" direction="out" :data="outbound" />
<Naive v-if="outbound.type == outTypes.Naive" direction="out" :data="outbound" />
<ShadowTls v-if="outbound.type == outTypes.ShadowTLS" :data="outbound" />
<Vless v-if="outbound.type == outTypes.VLESS" :data="outbound" />
<Tuic v-if="outbound.type == outTypes.TUIC" direction="out" :data="outbound" />
<Hysteria2 v-if="outbound.type == outTypes.Hysteria2" direction="out" :data="outbound" />
<AnyTls v-if="outbound.type == outTypes.AnyTls" :data="outbound" direction="out" />
<Tor v-if="outbound.type == outTypes.Tor" :data="outbound" />
<Ssh v-if="outbound.type == outTypes.SSH" :data="outbound" />
<Selector v-if="outbound.type == outTypes.Selector" :data="outbound" :tags="tags" />
<UrlTest v-if="outbound.type == outTypes.URLTest" :data="outbound" :tags="tags" />
<Transport v-if="Object.hasOwn(outbound,'transport')" :data="outbound" />
<OutTLS v-if="Object.hasOwn(outbound,'tls')" :outbound="outbound" />
<Multiplex v-if="Object.hasOwn(outbound,'multiplex')" direction="out" :data="outbound" />
<Dial v-if="!NoDial.includes(outbound.type)" :dial="outbound" />
</v-window-item>
<v-window-item value="t2">
<v-row>
<v-col cols="12">
<v-text-field v-model="link" :label="$t('client.external')" hide-details />
</v-col>
<v-col cols="12" align="center">
<v-btn hide-details variant="tonal" :loading="loading" @click="linkConvert">{{ $t('submit') }}</v-btn>
</v-col>
</v-row>
</v-window-item>
</v-window>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="primary"
variant="tonal"
:loading="loading"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { OutTypes, createOutbound } from '@/types/outbounds'
import RandomUtil from '@/plugins/randomUtil'
import Dial from '@/components/Dial.vue'
import Multiplex from '@/components/Multiplex.vue'
import Transport from '@/components/Transport.vue'
import OutTLS from '@/components/tls/OutTLS.vue'
import Direct from '@/components/protocols/Direct.vue'
import Socks from '@/components/protocols/Socks.vue'
import Http from '@/components/protocols/Http.vue'
import Shadowsocks from '@/components/protocols/Shadowsocks.vue'
import Vmess from '@/components/protocols/Vmess.vue'
import Trojan from '@/components/protocols/Trojan.vue'
import Wireguard from '@/components/protocols/Wireguard.vue'
import Hysteria from '@/components/protocols/Hysteria.vue'
import Naive from '@/components/protocols/Naive.vue'
import ShadowTls from '@/components/protocols/OutShadowTls.vue'
import Vless from '@/components/protocols/Vless.vue'
import Tuic from '@/components/protocols/Tuic.vue'
import Hysteria2 from '@/components/protocols/Hysteria2.vue'
import Tor from '@/components/protocols/Tor.vue'
import Ssh from '@/components/protocols/Ssh.vue'
import Selector from '@/components/protocols/Selector.vue'
import UrlTest from '@/components/protocols/UrlTest.vue'
import HttpUtils from '@/plugins/httputil'
import AnyTls from '@/components/protocols/AnyTls.vue'
import Data from '@/store/modules/data'
export default {
props: ['visible', 'data', 'id', 'tags'],
emits: ['close'],
data() {
return {
outbound: createOutbound("direct",{ "tag": "" }),
title: "add",
tab: "t1",
link: "",
loading: false,
outTypes: OutTypes,
NoDial: [OutTypes.Selector, OutTypes.URLTest],
NoServer: [OutTypes.Direct, OutTypes.Selector, OutTypes.URLTest, OutTypes.Tor],
}
},
methods: {
updateData(id: number) {
if (id > 0) {
const newData = JSON.parse(this.$props.data)
this.outbound = createOutbound(newData.type, newData)
this.title = "edit"
}
else {
this.outbound = createOutbound("direct",{ tag: "direct-" + RandomUtil.randomSeq(3) })
this.title = "add"
}
this.tab = "t1"
},
changeType() {
// Tag change only in add outbound
const tag = this.$props.id > 0 ? this.outbound.tag : this.outbound.type + "-" + RandomUtil.randomSeq(3)
// Use previous data
const prevConfig = { id: this.outbound.id, tag: tag, listen: this.outbound.listen, listen_port: this.outbound.listen_port }
this.outbound = createOutbound(this.outbound.type, prevConfig)
},
closeModal() {
this.updateData(0) // reset
this.$emit('close')
},
async saveChanges() {
if (!this.$props.visible) return
// check duplicate tag
const isDuplicatedTag = Data().checkTag("outbound",this.$props.id, this.outbound.tag)
if (isDuplicatedTag) return
// save data
this.loading = true
const success = await Data().save("outbounds", this.$props.id == 0 ? "new" : "edit", this.outbound)
if (success) this.closeModal()
this.loading = false
},
async linkConvert() {
if (this.link.length>0){
this.loading = true
const msg = await HttpUtils.post('api/linkConvert', { link: this.link })
this.loading = false
if (msg.success) {
this.outbound = msg.obj
if (this.$props.id > 0) this.outbound.id = this.$props.id
this.tab = "t1"
this.link = ""
}
}
}
},
watch: {
visible(newValue) {
if (newValue) {
this.updateData(this.$props.id)
}
},
},
components: { Dial, Multiplex, Transport, OutTLS,
Direct, Socks, Http, Shadowsocks, Vmess, Trojan,
Wireguard, Hysteria, Naive, ShadowTls, Vless, Tuic,
Hysteria2, AnyTls, Tor, Ssh, Selector, UrlTest }
}
</script>
@@ -0,0 +1,155 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800" :model-value="visible">
<v-card class="rounded-lg">
<v-card-title>
{{ $t('actions.addbulk') }} {{ $t('objects.outbound') }}
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px; overflow-y: scroll;">
<v-row v-if="outbounds.length==0">
<v-col cols="12">
<v-text-field v-model="link"
dir="ltr"
:label="$t('client.sub')"
placeholder="http[s]://<domain>[:]<port>/<path>"
hide-details />
</v-col>
<v-col cols="12">
<v-checkbox v-model="addUrlTest" :label="$t('out.addUrlTest')" />
</v-col>
<v-col cols="12" align="center">
<v-btn hide-details variant="tonal" :loading="loading" @click="linkCheck">{{ $t('submit') }}</v-btn>
</v-col>
</v-row>
<v-data-table
v-if="outbounds.length>0"
:items="outbounds"
:loading="loading"
:items-per-page="0"
hide-default-footer
density="compact"
:headers="[
{ value: 'check' },
{ title: $t('type'), value: 'type' },
{ title: $t('objects.tag'), value: 'tag' },
{ title: $t('out.addr'), value: 'server' },
{ title: $t('objects.tls'), value: 'tls' }
]"
>
<template v-slot:[`item.check`]="{ index }">
<v-icon color="success" icon="mdi-check" v-if="outChecks[index]==1" />
<v-icon color="error" icon="mdi-close" v-else-if="outChecks[index]==2" />
<v-progress-circular v-else-if="outChecks[index]==3" indeterminate />
<v-icon v-else icon="mdi-help"></v-icon>
</template>
<template v-slot:[`item.type`]="{ item }">
{{ item.type }}
</template>
<template v-slot:[`item.tag`]="{ item }">
{{ item.tag }}
</template>
<template v-slot:[`item.tls`]="{ item }">
{{ Object.hasOwn(item,'tls') ? $t(item.tls?.enabled ? 'enable' : 'disable') : '-' }}
</template>
<template v-slot:[`item.server`]="{ item }">
{{ item.server }}{{ item.server_port ? ':' + item.server_port : '' }}
</template>
</v-data-table>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="outlined" @click="closeModal">{{ $t('actions.close') }}</v-btn>
<v-btn color="primary" variant="tonal" :loading="loading" :disabled="outbounds.length==0" @click="saveChanges">{{ $t('actions.save') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import HttpUtils from '@/plugins/httputil'
import RandomUtil from '@/plugins/randomUtil';
import Data from '@/store/modules/data'
import { createOutbound, Outbound } from '@/types/outbounds'
export default {
props: ['visible', 'outboundTags'],
emits: ['close'],
data() {
return {
loading: false,
link: "",
outbounds: <Outbound[]>[],
outChecks: <number[]>[],
addUrlTest: false,
}
},
methods: {
resetData() {
this.outbounds = []
this.outChecks = []
this.link = ""
this.addUrlTest = false
this.loading = false
},
closeModal() {
this.resetData()
this.$emit('close')
},
async linkCheck() {
this.loading = true
this.outbounds = []
const msg = await HttpUtils.post('api/subConvert', { link: this.link })
if (msg.success) {
if (msg.obj?.length>0) {
msg.obj.forEach((o:any, index:number) => {
if (this.newOutboundTags.includes(o.tag)) o.tag = o.tag + "-" + (index+1)
this.outbounds.push(createOutbound(o.type, o))
this.outChecks.push(0)
})
if (this.addUrlTest) {
const urlTestTsg = "urltest-" + RandomUtil.randomSeq(3)
this.outbounds.push(createOutbound("urltest", {
tag: urlTestTsg,
outbounds: this.outbounds.map((o:Outbound) => o.tag),
interrupt_exist_connections: false,
interval: "30s"
}))
}
}
}
this.loading = false
},
async saveChanges() {
if (!this.$props.visible) return
// check duplicate tag
this.outbounds.forEach((o:Outbound, index:number) => {
const isDuplicatedTag = Data().checkTag("outbound",0, o.tag)
this.outChecks[index] = isDuplicatedTag ? 2 : 0
})
// save data
this.loading = true
this.outbounds.forEach(async (o:Outbound, index:number) => {
if (this.outChecks[index] == 2) return
this.outChecks[index] = 3
const success = await Data().save("outbounds", "new", o)
if (success) this.outChecks[index] = 1
else this.outChecks[index] = 2
})
this.loading = false
}
},
computed: {
newOutboundTags(): string[] {
return this.outbounds.map((o:Outbound) => o.tag)
}
},
watch: {
visible(v) {
if (v) {
this.resetData()
}
},
},
}
</script>
+150
View File
@@ -0,0 +1,150 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="400">
<v-card class="rounded-lg" id="qrcode-modal" :loading="loading">
<v-card-title>
<v-row>
<v-col>QrCode</v-col>
<v-spacer></v-spacer>
<v-col cols="auto"><v-icon icon="mdi-close-box" @click="$emit('close')" /></v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-skeleton-loader
class="mx-auto border"
width="80%"
type="text, image, divider, text, image"
v-if="loading"
></v-skeleton-loader>
<v-card-text style="overflow-y: auto; padding: 0" :hidden="loading">
<v-tabs
v-model="tab"
density="compact"
fixed-tabs
align-tabs="center"
>
<v-tab value="sub">{{ $t('setting.sub') }}</v-tab>
<v-tab value="link">{{ $t('client.links') }}</v-tab>
</v-tabs>
<v-window v-model="tab" style="margin-top: 10px;">
<v-window-item value="sub">
<v-row>
<v-col style="text-align: center;">
<v-chip>{{ $t('setting.sub') }}</v-chip><br />
<QrcodeVue :value="clientSub" :size="size" @click="copyToClipboard(clientSub)" :margin="1" style="border-radius: 1rem; cursor: copy;" />
</v-col>
</v-row>
<v-row>
<v-col style="text-align: center;">
<v-chip>{{ $t('setting.jsonSub') }}</v-chip><br />
<QrcodeVue :value="clientSub + '?format=json'" :size="size" @click="copyToClipboard(clientSub + '?format=json')" :margin="1" style="border-radius: 1rem; cursor: copy;" />
</v-col>
</v-row>
<v-row>
<v-col style="text-align: center;">
<v-chip>{{ $t('setting.clashSub') }}</v-chip><br />
<QrcodeVue :value="clientSub + '?format=clash'" :size="size" @click="copyToClipboard(clientSub + '?format=clash')" :margin="1" style="border-radius: 1rem; cursor: copy;" />
</v-col>
</v-row>
<v-row>
<v-col style="text-align: center;">
<v-chip>SING-BOX (scan only)</v-chip><br />
<QrcodeVue :value="singbox" :size="size" :margin="1" style="border-radius: .8rem; cursor: not-allowed;" />
</v-col>
</v-row>
</v-window-item>
<v-window-item value="link">
<v-row v-for="l in clientLinks">
<v-col style="text-align: center;">
<v-chip>{{ l.remark?? $t('client.' + l.type) }}</v-chip><br />
<QrcodeVue :value="l.uri" :size="size" @click="copyToClipboard(l.uri)" :margin="1" style="border-radius: .5rem; cursor: copy;" />
</v-col>
</v-row>
</v-window-item>
</v-window>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import QrcodeVue from 'qrcode.vue'
import Data from '@/store/modules/data'
import Clipboard from 'clipboard'
import { i18n } from '@/locales'
import { push } from 'notivue'
export default {
props: ['id', 'visible'],
data() {
return {
tab: "sub",
client: <any>{},
loading: false,
}
},
methods: {
async load() {
this.loading = true
const newData = await Data().loadClients(this.$props.id)
this.client = newData
this.loading = false
},
copyToClipboard(txt:string) {
const hiddenButton = document.createElement('button')
hiddenButton.className = 'clipboard-btn'
document.body.appendChild(hiddenButton)
const clipboard = new Clipboard('.clipboard-btn', {
text: () => txt,
container: document.getElementById('qrcode-modal')?? undefined
});
clipboard.on('success', () => {
clipboard.destroy()
push.success({
message: i18n.global.t('success') + ": " + i18n.global.t('copyToClipboard'),
duration: 5000,
})
})
clipboard.on('error', () => {
clipboard.destroy()
push.error({
message: i18n.global.t('failed') + ": " + i18n.global.t('copyToClipboard'),
duration: 5000,
})
})
// Perform click on hidden button to trigger copy
hiddenButton.click()
document.body.removeChild(hiddenButton)
}
},
computed: {
clientSub() {
return Data().subURI + this.client.name
},
singbox() {
const url = Data().subURI + this.client.name + "?format=json"
return "sing-box://import-remote-profile?url=" + encodeURIComponent(url) + "#" + this.client.name
},
clientLinks() {
return this.client.links?? []
},
size() {
if (window.innerWidth > 380) return 300
if (window.innerWidth > 330) return 280
return 250
}
},
watch: {
visible(v) {
if (v) {
this.tab = "sub"
this.load()
}
},
},
components: { QrcodeVue }
}
</script>
+324
View File
@@ -0,0 +1,324 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
{{ $t('actions.' + title) + " " + $t('objects.rule') }}
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px;">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="logical" :label="$t('rule.logical')" hide-details></v-switch>
</v-col>
<v-spacer></v-spacer>
<v-col cols="auto" v-if="logical" justify="center" align="center">
<v-btn color="primary" @click="ruleData.rules.push(<rule>{})" hide-details>{{ $t('actions.add') + " " + $t('objects.rule') }}</v-btn>
</v-col>
</v-row>
<v-card style="background-color: inherit; margin-bottom: 5px;" v-for="(r, index) in ruleData.rules" v-if="ruleData.type == 'logical'">
<v-card-subtitle>{{ $t('objects.rule') + ' ' + (Number(index)+1) }}
<v-icon @click="ruleData.rules.splice(index,1)" icon="mdi-delete" v-if="ruleData.rules.length>1" />
</v-card-subtitle>
<v-card-text style="padding: 0;">
<RuleOptions
:rule="r"
:clients="clients"
:inTags="inTags"
:outTags="outTags"
:rsTags="rsTags" />
</v-card-text>
</v-card>
<RuleOptions
v-else
:rule="ruleData.rules[0]"
:clients="clients"
:inTags="inTags"
:outTags="outTags"
:rsTags="rsTags" />
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
v-model="ruleData.action"
:items="actions"
:label="$t('admin.action')"
hide-details
></v-select>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="logical">
<v-combobox
v-model="ruleData.mode"
:items="['and', 'or']"
:label="$t('rule.mode')"
hide-details
></v-combobox>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="ruleData.invert" :label="$t('rule.invert')" hide-details></v-switch>
</v-col>
</v-row>
<v-card subtitle="Route" v-if="ruleData.action == 'route'">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
v-model="ruleData.outbound"
:items="outTags"
:label="$t('objects.outbound')"
hide-details
></v-select>
</v-col>
</v-row>
</v-card>
<v-card subtitle="Route Option" v-if="ruleData.action == 'route-options'">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="ruleData.override_address" :label="$t('types.direct.overrideAddr')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
v-model.number="ruleData.override_port"
type="number"
min="0"
max="65534"
:label="$t('types.direct.overridePort')"
hide-details>
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="ruleData.udp_disable_domain_unmapping" :label="$t('rule.udpDisableDomainUnmapping')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="ruleData.udp_connect" :label="$t('rule.udpConnect')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="ruleData.udp_timeout" :label="$t('rule.udpTimeout')" hide-details></v-text-field>
</v-col>
</v-row>
</v-card>
<v-card subtitle="Reject" v-if="ruleData.action == 'reject'">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
v-model="ruleData.method"
:items="[{ title: 'Default', value: 'default' },{ title: 'Drop', value: 'drop'}]"
:label="$t('rule.method')"
clearable
@click:clear="delete ruleData.method"
hide-details>
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="ruleData.no_drop" :label="$t('rule.noDrop')" hide-details></v-switch>
</v-col>
</v-row>
</v-card>
<v-card subtitle="Sniff" v-if="ruleData.action == 'sniff'">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
v-model="ruleData.sniffer"
:items="sniffers"
:label="$t('rule.sniffer')"
multiple
chips
hide-details>
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="ruleData.timeout" :label="$t('rule.timeout')" hide-details></v-text-field>
</v-col>
</v-row>
</v-card>
<v-card subtitle="Resolve" v-if="ruleData.action == 'resolve'">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
v-model="ruleData.strategy"
:items="strategies"
:label="$t('rule.strategy')"
clearable
@click:clear="delete ruleData.strategy"
hide-details>
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="ruleData.server" :label="$t('basic.dns.server')" hide-details></v-text-field>
</v-col>
</v-row>
</v-card>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="primary"
variant="tonal"
:loading="loading"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { logicalRule, rule, actionKeys } from '@/types/rules'
import RuleOptions from '@/components/Rule.vue'
export default {
props: ['visible', 'data', 'index', 'clients', 'inTags', 'outTags', 'rsTags'],
emits: ['close', 'save'],
data() {
return {
title: 'add',
loading: false,
ruleData: <any>{
type: 'logical',
mode: 'and',
rules: <rule[]>[{}],
invert: false,
action: 'route',
outbound: 'direct',
},
actions: [
{ title: 'Route', value: 'route'},
{ title: 'Route Options', value: 'route-options'},
{ title: 'Bypass', value: 'bypass'},
{ title: 'Reject', value: 'reject'},
{ title: 'Hijack DNS', value: 'hijack-dns'},
{ title: 'Sniff', value: 'sniff'},
{ title: 'Resolve', value: 'resolve'}
],
sniffers: [
{ title: 'HTTP', value: 'http' },
{ title: 'TLS', value: 'tls' },
{ title: 'QUIC', value: 'quic' },
{ title: 'STUN', value: 'stun' },
{ title: 'DNS', value: 'dns' },
{ title: 'BitTorrent', value: 'bittorrent' },
{ title: 'DTLS', value: 'dtls' },
{ title: 'SSH', value: 'ssh' },
{ title: 'RDP', value: 'rdp' },
{ title: 'NTP', value: 'ntp' },
],
strategies: [
{ title: 'Prefer IPv4', value: 'prefer_ipv4' },
{ title: 'Prefer IPv6', value: 'prefer_ipv6' },
{ title: 'IPv4 Only', value: 'ipv4_only' },
{ title: 'IPv6 Only', value: 'ipv6_only' },
]
}
},
methods: {
updateData() {
if (this.$props.index != -1) {
const newData = JSON.parse(this.$props.data)
if (newData.type) {
this.ruleData = newData
} else {
this.ruleData = {
type: 'simple',
mode: 'and',
rules: <rule[]>[{}],
}
Object.keys(newData).forEach(key => {
if (actionKeys.includes(key)) {
this.ruleData[key] = newData[key]
} else {
this.ruleData.rules[0][key] = newData[key]
}
})
}
this.title = 'edit'
}
else {
this.ruleData = <logicalRule>{
type: 'simple',
mode: 'and',
rules: <rule[]>[{}],
invert: false,
action: 'route',
outbound: this.$props.outTags[0]?? 'direct',
}
this.title = 'add'
}
},
closeModal() {
this.updateData() // reset
this.$emit('close')
},
saveChanges() {
this.loading = true
let newRule = <any>{
action: this.ruleData.action,
invert: this.ruleData.invert? this.ruleData.invert : undefined,
}
// Filter action data
switch (newRule.action){
case 'route':
newRule.outbound = this.ruleData.outbound
break
case 'route-options':
newRule.override_address = this.ruleData.override_address?.length > 0 ? this.ruleData.override_address : undefined
newRule.override_port = this.ruleData?.override_port > 0 ? this.ruleData.override_port : undefined
newRule.network_strategy = this.ruleData.network_strategy?.length > 0 ? this.ruleData.network_strategy : undefined
newRule.fallback_delay = this.ruleData.fallback_delay?.length > 0 ? this.ruleData.fallback_delay : undefined
newRule.udp_disable_domain_unmapping = this.ruleData.udp_disable_domain_unmapping? true : undefined
newRule.udp_connect = this.ruleData.udp_connect? true : undefined
newRule.udp_timeout = this.ruleData.udp_timeout?.length > 0 ? this.ruleData.udp_timeout : undefined
break
case 'reject':
newRule.method = this.ruleData.method?.length > 0 ? this.ruleData.method : undefined
newRule.no_drop = this.ruleData.no_drop? true : undefined
break
case 'sniff':
newRule.sniffer = this.ruleData.sniffer?.length > 0 ? this.ruleData.sniffer : undefined
newRule.timeout = this.ruleData.timeout?.length > 0 ? this.ruleData.timeout : undefined
break
case 'resolve':
newRule.strategy = this.ruleData.strategy?.length > 0 ? this.ruleData.strategy : undefined
newRule.server = this.ruleData.server?.length > 0 ? this.ruleData.server : undefined
break
}
// Add rules
if (this.ruleData.type == 'simple'){
newRule = { ...this.ruleData.rules[0], ...newRule }
} else {
newRule.type = 'logical'
newRule.mode = this.ruleData.mode
newRule.rules = this.ruleData.rules
}
this.$emit('save', newRule)
this.loading = false
},
deleteRule(index:number) {
this.ruleData.rules.splice(index,1)
}
},
computed: {
logical: {
get() { return this.ruleData.type == 'logical' },
set(v:boolean) {
this.ruleData.type = v? 'logical' : 'simple'
}
}
},
watch: {
visible(newValue) {
if (newValue) {
this.updateData()
}
},
},
components: { RuleOptions }
}
</script>
+259
View File
@@ -0,0 +1,259 @@
<template>
<v-dialog transition="dialog-top-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
<v-row>
<v-col>
{{ $t('rule.import.rulesTitle') }}
</v-col>
<v-col cols="auto">
<v-chip v-if="parsed" size="small" color="primary" variant="tonal">
{{ parsed.rules?.length ?? 0 }} {{ $t('pages.rules') }} · {{ parsed.rule_set?.length ?? 0 }} {{ $t('rule.ruleset') }}
</v-chip>
</v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px; overflow-y: scroll;">
<v-tabs v-model="tab" @update:modelValue="tabChanged">
<v-tab value="json">JSON</v-tab>
<v-tab value="file">{{ $t('rule.import.uploadFile') }}</v-tab>
<v-tab value="url">{{ $t('rule.import.fromUrl') }}</v-tab>
</v-tabs>
<v-window v-model="tab">
<v-window-item value="json">
<v-alert variant="text" type="info">{{ $t('rule.import.jsonHint') }}</v-alert>
<v-textarea
v-model="rawJson"
label="JSON"
variant="outlined"
rows="12"
hide-details
spellcheck="false"
class="mb-4"
:error="!!error"></v-textarea>
</v-window-item>
<v-window-item value="file">
<v-alert variant="text" type="info">{{ $t('rule.import.fileJsonHint') }}</v-alert>
<v-file-input
:label="$t('rule.import.selectJson')"
accept=".json"
variant="outlined"
hide-details
prepend-icon="mdi-file-code"
clearable
@click:clear="tabChanged"
@update:modelValue="onFileUpload($event)" />
</v-window-item>
<v-window-item value="url">
<v-alert variant="text" type="info">{{ $t('rule.import.urlHint') }}</v-alert>
<v-text-field
v-model="fetchUrl"
label="URL"
variant="outlined"
hide-details
spellcheck="false"
placeholder="https://raw.githubusercontent.com/.../rules.json"
append-icon="mdi-download"
@keydown.enter="fetchFromUrl"
@click:append="fetchFromUrl" />
</v-window-item>
</v-window>
<v-alert v-if="error" type="error" variant="text" v-html="error"></v-alert>
<template v-if="parsed">
<v-divider class="my-4" />
<v-alert v-if="hasConflicts" type="warning" variant="tonal" :title="$t('rule.import.conflict')" class="mb-4">
{{ $t('rule.import.conflictDesc', { rules: existingRulesCount, rulesets: existingRulesetsCount }) }}
<v-radio-group v-model="mode" hide-details class="mt-2">
<v-radio value="merge" :label="$t('rule.import.merge')" />
<v-radio value="replace" :label="$t('rule.import.replace')" />
</v-radio-group>
</v-alert>
<v-alert v-if="parsed.final" type="info" variant="tonal" class="mb-4">
{{ $t('rule.import.finalOutbound') }}:
<v-chip size="small" color="secondary" variant="tonal">{{ parsed.final }}</v-chip>
<v-checkbox v-model="applyFinal" :label="$t('rule.import.applyFinal')" hide-details density="compact" />
</v-alert>
<span class="v-card-subtitle">
{{ $t('pages.rules') }}
<v-badge v-if="parsed.rules?.length > 0" color="success" :content="parsed.rules?.length" inline />
</span>
<v-table v-if="parsed.rules?.length" density="compact" class="mb-4" striped="even">
<thead>
<tr><th>#</th><th>{{ $t('type') }}</th><th>{{ $t('admin.action') }}</th><th>{{ $t('objects.outbound') }}</th></tr>
</thead>
<tbody>
<tr v-for="(r, i) in parsed.rules" :key="i">
<td>{{ (i as number) + 1 }}</td>
<td>{{ r.type ?? 'simple' }}</td>
<td>{{ r.action }}</td>
<td>{{ r.outbound ?? '-' }}</td>
</tr>
</tbody>
</v-table>
<span class="v-card-subtitle">
{{ $t('rule.ruleset') }}
<v-badge v-if="parsed.rule_set?.length > 0" color="success" :content="parsed.rule_set?.length" inline />
<span v-if="skippedRulesets > 0">
<v-badge color="warning" :content="skippedRulesets" inline v-tooltip:top="$t('rule.import.skipped')" />
</span>
</span>
<v-table v-if="parsed.rule_set?.length" density="compact" striped="even">
<thead>
<tr><th>{{ $t('objects.tag') }}</th><th>{{ $t('ruleset.format') }}</th><th>{{ $t('type') }}</th><th>{{ $t('ruleset.interval') }}</th></tr>
</thead>
<tbody>
<tr v-for="(rs, i) in parsed.rule_set" :key="i"
:style="mode === 'merge' && existingRulesetTags.includes(rs.tag) ? 'opacity:0.4' : ''">
<td style="font-family: monospace;">{{ rs.tag }}</td>
<td>{{ rs.format }}</td>
<td>{{ rs.type }}</td>
<td>{{ rs.update_interval ?? '-' }}</td>
</tr>
</tbody>
</v-table>
</template>
</v-card-text>
<v-card-actions>
<v-btn
v-if="tab === 'json'"
@click="parseJson"
variant="tonal"
color="success"
:disabled="rawJson.trim().length === 0"
>
{{ $t('rule.import.parse') }}
<v-icon icon="mdi-magnify" />
</v-btn>
<v-spacer />
<v-btn @click="close" variant="text">{{ $t('actions.close') }}</v-btn>
<v-btn @click="save" color="primary" variant="flat" :disabled="!parsed">
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
export default {
props: ["visible", "existingRulesCount", "existingRulesetsCount", "existingRulesetTags"],
emits: ['save', 'close'],
data() {
return {
tab: 'json',
rawJson: '',
fetchUrl: '',
fetching: false,
error: '',
parsed: null as any,
mode: 'merge' as 'merge' | 'replace',
applyFinal: false,
}
},
computed: {
hasConflicts(): boolean {
return this.existingRulesCount > 0 || this.existingRulesetsCount > 0
},
skippedRulesets(): number {
if (!this.parsed?.rule_set) return 0
const existing = new Set(this.existingRulesetTags)
return this.parsed.rule_set.filter((rs: any) => existing.has(rs.tag)).length
},
},
methods: {
tabChanged() {
this.rawJson = ''
this.fetchUrl = ''
this.error = ''
this.parsed = null
this.mode = this.hasConflicts ? 'merge' : 'replace'
this.applyFinal = false
},
extractRouteBlock(obj: any): any {
if (obj?.route && (obj.route.rules || obj.route.rule_set)) return obj.route
if (obj?.rules || obj?.rule_set) return obj
return null
},
setParsed(block: any) {
this.parsed = block
this.mode = this.hasConflicts ? 'merge' : 'replace'
this.applyFinal = false
},
reset() {
this.tab = 'json'
this.tabChanged()
},
parseJson() {
this.error = ''
this.parsed = null
try {
const block = this.extractRouteBlock(JSON.parse(this.rawJson))
if (!block) {
this.error = this.$t('rule.import.errNoArrays')
return
}
this.setParsed(block)
} catch (e: any) {
this.error = this.$t('rule.import.errJsonParse', { message: e.message })
}
},
async fetchFromUrl() {
this.error = ''
this.parsed = null
this.fetching = true
try {
const resp = await fetch(this.fetchUrl)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const block = this.extractRouteBlock(await resp.json())
if (!block) this.error = this.$t('rule.import.errNoArraysFetched')
else this.setParsed(block)
} catch (e: any) {
this.error = this.$t('rule.import.errFetch', { message: e.message })
} finally {
this.fetching = false
}
},
async onFileUpload(files: File | File[] | null) {
this.error = ''
this.parsed = null
const file = Array.isArray(files) ? files[0] : files
if (!file) {
this.error = this.$t('rule.import.errNoFile')
return
}
try {
const block = this.extractRouteBlock(JSON.parse(await file.text()))
if (!block) {
this.error = this.$t('rule.import.errNoArraysInFile')
return
}
this.setParsed(block)
} catch (e: any) {
this.error = this.$t('rule.import.errJsonParse', { message: e.message })
return
}
},
save() {
if (!this.parsed) return
this.$emit('save', this.parsed, this.mode, this.applyFinal)
},
close() {
this.$emit('close')
},
},
watch: {
visible(v: boolean) {
if (v) this.reset()
},
},
}
</script>
+133
View File
@@ -0,0 +1,133 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
{{ $t('actions.' + title) + " " + $t('objects.ruleset') }}
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px;">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('type')"
:items="[{title: $t('ruleset.local'), value: 'local'},{ title: $t('ruleset.remote'), value: 'remote'}]"
@update:model-value="updateType($event)"
v-model="rule_set.type">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="rule_set.tag" :label="$t('objects.tag')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('ruleset.format')"
:items="['source', 'binary']"
v-model="rule_set.format">
</v-select>
</v-col>
</v-row>
<v-row v-if="rule_set.type == 'local'">
<v-col cols="12">
<v-text-field v-model="rule_set.path" :label="$t('transport.path')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12">
<v-text-field v-model="rule_set.url" label="URL" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('objects.outbound')"
:items="outTags"
clearable
@click:clear="delete rule_set.download_detour"
v-model="rule_set.download_detour">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model.number="update_intervals" :suffix="$t('date.d')" type="number" min="0" :label="$t('ruleset.interval')" hide-details></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="primary"
variant="tonal"
:loading="loading"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import RandomUtil from '@/plugins/randomUtil'
import { ruleset } from '@/types/rules'
export default {
props: ['visible', 'data', 'index', 'outTags'],
emits: ['close', 'save'],
data() {
return {
title: "add",
loading: false,
rule_set: <ruleset>{},
}
},
methods: {
updateData() {
if (this.$props.index != -1) {
this.title = "edit"
this.rule_set = <ruleset>JSON.parse(this.$props.data)
}
else {
this.title = "add"
this.rule_set = <ruleset>{type: 'local', tag: "rs-" + RandomUtil.randomSeq(3), format: 'binary'}
}
},
updateType(t:string) {
if (t == 'local') {
delete this.rule_set.url
delete this.rule_set.download_detour
delete this.rule_set.update_interval
} else {
delete this.rule_set.path
}
},
closeModal() {
this.$emit('close')
},
saveChanges() {
this.loading = true
this.$emit('save', this.rule_set)
this.loading = false
}
},
computed: {
update_intervals: {
get() { return this.rule_set.update_interval != undefined ? parseInt(this.rule_set.update_interval.replace('d','')) : 0 },
set(v:number) { this.rule_set.update_interval = v>0 ? v + 'd' : undefined }
},
},
watch: {
visible(newValue) {
if (newValue) {
this.updateData()
}
},
},
}
</script>
@@ -0,0 +1,187 @@
<template>
<v-dialog transition="dialog-top-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
<v-row>
<v-col>{{ $t('rule.import.title') }}</v-col>
<v-col cols="auto" v-if="importPreview.length > 0">
<v-chip size="small" color="primary" variant="tonal">
{{ $t('count') }}: {{ importPreview.length }}
</v-chip>
</v-col>
</v-row>
</v-card-title>
<v-divider />
<v-card-text style="padding: 0 16px; overflow-y: scroll;">
<v-tabs v-model="tab" @update:modelValue="tabChanged">
<v-tab value="text">
{{ $t('rule.import.pasteUrls') }}
</v-tab>
<v-tab value="file">
{{ $t('rule.import.uploadTxt') }}
</v-tab>
</v-tabs>
<v-window v-model="tab" class="mb-4">
<v-window-item value="text">
<v-alert variant="text" type="info">{{ $t('rule.import.urlsHint') }}</v-alert>
<v-textarea
v-model="importRawText"
label="URLs"
variant="outlined"
rows="10"
auto-grow
hide-details
spellcheck="false"
placeholder="https://github.com/.../geoip-telegram.srs&#10;https://github.com/.../geosite-youtube.srs"
></v-textarea>
</v-window-item>
<v-window-item value="file">
<v-alert variant="text" type="info">{{ $t('rule.import.fileHint') }}</v-alert>
<v-file-input
:label="$t('rule.import.selectTxt')"
accept=".txt"
variant="outlined"
hide-details
prepend-icon="mdi-file-document"
@change="onFileUpload"
></v-file-input>
</v-window-item>
</v-window>
<v-row class="mb-4">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('ruleset.format')"
:items="['source', 'binary']"
v-model="importFormat">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('objects.outbound')"
:items="outTags"
clearable
@click:clear="importDetour=''"
v-model="importDetour">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model.number="importInterval" :suffix="$t('date.d')" type="number" min="0" :label="$t('ruleset.interval')" hide-details></v-text-field>
</v-col>
</v-row>
<template v-if="importPreview.length > 0">
<v-divider class="my-4" />
<span class="v-card-subtitle">
{{ $t('rule.import.preview') }}
<v-badge v-if="importPreview.length > 0" color="success" :content="importPreview.length" inline />
<v-badge v-if="importSkipped > 0" color="warning" :content="importSkipped" inline v-tooltip:top="$t('rule.import.skipped')" />
</span>
<v-table density="compact" striped="even" class="mb-4">
<thead>
<tr><th>{{ $t('objects.tag') }}</th><th>{{ $t('ruleset.format') }}</th><th>URL</th><th>{{ $t('actions.del') }}</th></tr>
</thead>
<tbody>
<tr v-for="(item, i) in importPreview" :key="i" :style="item.exists ? 'opacity:0.4' : ''">
<td>
{{ item.tag }}
</td>
<td>{{ item.format }}</td>
<td v-tooltip:top="item.url" dir="ltr">.../{{ item.url.split('/').pop() ?? item.url }}</td>
<td><v-icon icon="mdi-delete" color="error" @click="importPreview.splice(i, 1)" /></td>
</tr>
</tbody>
</v-table>
</template>
</v-card-text>
<v-divider />
<v-card-actions class="pa-3">
<v-btn @click="parseImport" variant="tonal" :disabled="importRawText.trim().length === 0">
<v-icon icon="mdi-magnify" class="mr-1" />{{ $t('rule.import.parse') }}
</v-btn>
<v-spacer />
<v-btn @click="close" variant="text">{{ $t('actions.close') }}</v-btn>
<v-btn @click="save" color="primary" variant="flat" :disabled="newCount === 0">
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
interface ImportItem { tag: string; url: string; format: string; exists: boolean }
export default {
props: ["visible", "outTags", "rsTags"],
emits: ['save', 'close'],
data() {
return {
tab: 'text',
importRawText: '',
importFormat: 'binary',
importDetour: '',
importInterval: 1,
importPreview: [] as ImportItem[],
}
},
computed: {
importSkipped(): number {
return this.importPreview.filter(i => i.exists).length
},
newCount(): number {
return this.importPreview.filter(i => !i.exists).length
},
},
methods: {
tabChanged() {
this.importPreview = []
this.importRawText = ''
},
urlToTag(url: string): string {
try {
const filename = new URL(url).pathname.split('/').pop() ?? ''
return filename.replace(/\.[^.]+$/, '')
} catch {
const parts = url.split('/')
return parts[parts.length - 1].replace(/\.[^.]+$/, '') || url
}
},
close() {
this.$emit('close')
},
parseImport() {
const existingTags = new Set(this.rsTags)
const seen = new Set<string>()
this.importPreview = this.importRawText
.split('\n').map(l => l.trim()).filter(l => l.length > 0 && l.startsWith('http'))
.filter(url => { if (seen.has(url)) return false; seen.add(url); return true })
.map(url => ({ tag: this.urlToTag(url), url, format: this.importFormat, exists: existingTags.has(this.urlToTag(url)) }))
},
save() {
const toAdd = this.importPreview.filter(i => !i.exists).map(item => {
const rs: any = { type: 'remote', tag: item.tag, format: item.format, url: item.url }
if (this.importDetour) rs.download_detour = this.importDetour
if (this.importInterval > 0) rs.update_interval = this.importInterval + 'd'
return rs
})
this.$emit('save', toAdd)
},
async onFileUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
this.importRawText = await file.text()
this.parseImport()
},
},
watch: {
visible(v) {
if (v) {
this.tab = 'text'
this.tabChanged()
}
},
},
}
</script>
+128
View File
@@ -0,0 +1,128 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
{{ $t('actions.' + title) + " " + $t('objects.service') }}
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px; overflow-y: scroll;">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:label="$t('type')"
:items="Object.keys(srvTypes).map((key,index) => ({title: key, value: Object.values(srvTypes)[index]}))"
v-model="srv.type"
@update:modelValue="changeType">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="srv.tag" :label="$t('objects.tag')" hide-details></v-text-field>
</v-col>
</v-row>
<Listen :data="srv" :inTags="inTags" />
<Derp v-if="srv.type == srvTypes.DERP" :data="srv" :inTags="inTags" :tsTags="tsTags" />
<SSMapi v-if="srv.type == srvTypes.SSMAPI" :data="srv" :ssTags="ssTags" />
<Ocm v-if="srv.type == srvTypes.OCM" :data="srv" />
<Ccm v-if="srv.type == srvTypes.CCM" :data="srv" />
<InTLS v-if="HasTls.includes(srv.type)" :inbound="srv" :tlsConfigs="tlsConfigs" :tls_id="srv.tls_id" />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="primary"
variant="tonal"
:loading="loading"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { SrvTypes, createSrv } from '@/types/services'
import RandomUtil from '@/plugins/randomUtil'
import Listen from '@/components/Listen.vue'
import Derp from '@/components/services/Derp.vue'
import Ocm from '@/components/services/Ocm.vue'
import Ccm from '@/components/services/Ccm.vue'
import InTLS from '@/components/tls/InTLS.vue'
import SSMapi from '@/components/services/SSMAPI.vue'
import Data from '@/store/modules/data'
export default {
props: ['visible', 'data', 'id', 'inTags', 'tsTags', 'ssTags', 'tlsConfigs'],
emits: ['close'],
data() {
return {
srv: createSrv("derp",{ "tag": "" }),
title: "add",
tab: "t1",
loading: false,
srvTypes: SrvTypes,
HasTls: [SrvTypes.DERP, SrvTypes.SSMAPI, SrvTypes.OCM, SrvTypes.CCM],
}
},
methods: {
async updateData(id: number) {
if (id > 0) {
const newData = JSON.parse(this.$props.data)
this.srv = createSrv(newData.type, newData)
this.title = "edit"
}
else {
const port = RandomUtil.randomIntRange(10000, 60000)
this.srv = createSrv("derp", {
tag: "derp-" + RandomUtil.randomSeq(3),
listen: '::',
listen_port: port,
})
this.title = "add"
}
this.tab = "t1"
},
changeType() {
// Tag change only in add service
const tag = this.$props.id > 0 ? this.srv.tag : this.srv.type + "-" + RandomUtil.randomSeq(3)
// Use previous data
const prevConfig = { id: this.srv.id, tag: tag, listen: this.srv.listen, listen_port: this.srv.listen_port }
this.srv = createSrv(this.srv.type, prevConfig)
},
closeModal() {
this.updateData(0) // reset
this.$emit('close')
},
async saveChanges() {
if (!this.$props.visible) return
// check duplicate tag
const isDuplicatedTag = Data().checkTag("service",this.srv.id, this.srv.tag)
if (isDuplicatedTag) return
// save data
this.loading = true
const success = await Data().save("services", this.$props.id == 0 ? "new" : "edit", this.srv)
if (success) this.closeModal()
this.loading = false
},
},
watch: {
visible(v) {
if (v) {
this.updateData(this.$props.id)
}
},
},
components: { Listen, InTLS, Derp, Ocm, Ccm, SSMapi },
}
</script>
+218
View File
@@ -0,0 +1,218 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg" :loading="loading">
<v-card-title>
<v-row>
<v-col cols="auto">
{{ $t('stats.graphTitle') }}
</v-col>
<v-spacer></v-spacer>
<v-col cols="auto"><v-icon icon="mdi-close" @click="$emit('close')"></v-icon></v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px;">
<div style="text-align: center; margin: 5px;">
{{ $t('objects.' + resource) + " : " + tag }}
</div>
<v-radio-group v-model="limit" @change="loadData" density="compact" :loading="loading" inline hide-details>
<v-radio v-for="p in periods" :label="p.title" :value="p.value"></v-radio>
</v-radio-group>
<v-container id="container" style="height:40vh;">
<v-skeleton-loader
class="mx-auto border"
width="95%"
type="image"
v-if="loading"
></v-skeleton-loader>
<template v-else>
<v-alert :text="$t('noData')" type="warning" variant="outlined" v-if="alert"></v-alert>
<Line v-if="loaded" :data="usage" :options="<any>options" />
</template>
</v-container>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { i18n } from '@/locales'
import HttpUtils from '@/plugins/httputil'
import { HumanReadable } from '@/plugins/utils'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js'
import { ref } from 'vue'
import { Line } from 'vue-chartjs'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
ChartJS.defaults.font.family = 'Vazirmatn'
export default {
components: {
Line
},
props: ['visible','resource','tag'],
data() {
return {
loading: false,
loaded: false,
alert: false,
intervalId: <any>0,
limit: 1,
periods: [
{ value: 1, title: i18n.global.n(1) + i18n.global.t('date.h')},
{ value: 6, title: i18n.global.n(6) + i18n.global.t('date.h')},
{ value: 12, title: i18n.global.n(12) + i18n.global.t('date.h')},
{ value: 24, title: i18n.global.n(1) + i18n.global.t('date.d')},
{ value: 48, title: i18n.global.n(2) + i18n.global.t('date.d')},
{ value: 240, title: i18n.global.n(10) + i18n.global.t('date.d')},
{ value: 480, title: i18n.global.n(20) + i18n.global.t('date.d')},
{ value: 720, title: i18n.global.n(30) + i18n.global.t('date.d')},
{ value: 1440, title: i18n.global.n(60) + i18n.global.t('date.d')},
{ value: 2160, title: i18n.global.n(90) + i18n.global.t('date.d')},
],
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
elements: {
point: { pointStyle: 'crossRot' }
},
plugins: {
tooltip: {
callbacks: {
text: (ctx:any) => {
const {axis = 'xy', intersect, mode} = ctx.chart.options.interaction
return 'Mode: ' + mode + ', axis: ' + axis + ', intersect: ' + intersect
},
footer: (items:any[]) => {
return HumanReadable.sizeFormat(items.reduce((acc, c) => acc + c.raw, 0))
}
}
}
},
scales: {
y: {
grid: {
color: '#777777',
},
beginAtZero: true,
ticks: {
callback: function(label:any, index: number) {
return label == 0 ? 0 : HumanReadable.sizeFormat(label,0)
},
count: 10
}
}
}
},
usage: ref(<any>{}),
}
},
methods: {
async loadData() {
this.loading = true
const data = await HttpUtils.get('api/stats', { resource: this.resource, tag: this.tag, limit: this.limit })
if (data.success && data.obj) {
const obj = <any[]>data.obj
const l = String(i18n.global.locale) == 'fa' ? "fa-IR" : "en-US"
const oneStep = this.limit * 3600 * 1000 / 360 // Each 10 sec
const now = new Date().getTime()
const steps = <number[]>[]
for (let i = 360; i >= 0; i--) {
steps.push(now - (oneStep * i))
}
const labels = <string[]>[]
const uplinkData = <number[]>[]
const downlinkData = <number[]>[]
for (let i = 1; i<360; i++) {
labels.push(this.genLable(steps[i],l))
let upSum:number
let downSum:number
const upTraffics = obj.filter(o => o.direction && o.dateTime*1000 < steps[i] && o.dateTime*1000 > steps[i-1]).map((o:any) => o.traffic)
upSum = upTraffics.length>0 ? upTraffics.reduce(u => u) : null
const downTraffics = obj.filter(o => !o.direction && o.dateTime*1000 < steps[i] && o.dateTime*1000 > steps[i-1]).map((o:any) => o.traffic)
downSum = downTraffics.length>0 ? downTraffics.reduce(d => d) : null
uplinkData.push(upSum)
downlinkData.push(downSum)
}
this.usage = {
labels: labels,
datasets: [
{
label: i18n.global.t('stats.upload'),
backgroundColor: 'rgba(255, 165, 0, 0.4)',
borderColor: 'rgba(255, 165, 0)',
fill: true,
data: uplinkData
},
{
label: i18n.global.t('stats.download'),
backgroundColor: 'rgba(0, 128, 0, 0.2)',
borderColor: 'rgba(0, 128, 0)',
fill: true,
data: downlinkData
}
],
}
this.loaded = true
this.alert = false
} else {
this.alert = true
this.loaded = false
}
this.loading = false
},
genLable(step:number, locale: string) {
return new Date(step).toLocaleString(locale,{
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
},
},
watch: {
visible(v) {
if (v) {
this.limit = 1
this.loadData()
this.intervalId = setInterval(() => {
this.loadData()
}, 10000)
} else {
this.loaded = false
this.alert = false
this.usage.labels = []
if (this.usage.datasets) {
this.usage.datasets[0].data = []
this.usage.datasets[1].data = []
}
if (this.intervalId && this.intervalId != 0) {
clearInterval(this.intervalId)
}
}
}
}
}
</script>
+592
View File
@@ -0,0 +1,592 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
{{ $t('actions.' + title) + " " + $t('objects.tls') }}
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px; overflow-y: scroll;">
<v-card class="rounded-lg">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('client.name')"
hide-details
v-model="tls.name">
</v-text-field>
</v-col>
<v-col align="end">
<v-btn-toggle v-model="tlsType"
class="rounded-xl"
density="compact"
variant="outlined"
@update:model-value="changeTlsType"
shaped
mandatory>
<v-btn>TLS</v-btn>
<v-btn>Reality</v-btn>
</v-btn-toggle>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="inTls.server_name != undefined">
<v-text-field
label="SNI"
hide-details
v-model="inTls.server_name">
</v-text-field>
</v-col>
<template v-if="tlsType == 0">
<v-col cols="12" sm="6" md="4" v-if="inTls.min_version">
<v-select
hide-details
:label="$t('tls.minVer')"
:items="tlsVersions"
v-model="inTls.min_version">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="inTls.max_version">
<v-select
hide-details
:label="$t('tls.maxVer')"
:items="tlsVersions"
v-model="inTls.max_version">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="inTls.alpn">
<v-select
hide-details
label="ALPN"
multiple
:items="alpn"
v-model="inTls.alpn">
</v-select>
</v-col>
<v-col cols="12" md="8" v-if="inTls.cipher_suites != undefined">
<v-select
hide-details
:label="$t('tls.cs')"
multiple
:items="cipher_suites"
v-model="inTls.cipher_suites">
</v-select>
</v-col>
</template>
</v-row>
<template v-if="tlsType == 0">
<v-row>
<v-col>
<v-btn-toggle v-model="usePath"
class="rounded-xl"
density="compact"
variant="outlined"
shaped
mandatory>
<v-btn
@click="inTls.key=undefined; inTls.certificate=undefined"
>{{ $t('tls.usePath') }}</v-btn>
<v-btn
@click="inTls.key_path=undefined; inTls.certificate_path=undefined"
>{{ $t('tls.useText') }}</v-btn>
</v-btn-toggle>
</v-col>
<v-spacer></v-spacer>
<v-col cols="auto">
<v-btn
variant="tonal"
density="compact"
icon="mdi-key-star"
@click="genSelfSigned"
:loading="loading">
<v-icon />
<v-tooltip activator="parent" location="top">
{{ $t('actions.generate') }}
</v-tooltip>
</v-btn>
</v-col>
</v-row>
<v-row v-if="usePath == 0">
<v-col cols="12" sm="6">
<v-text-field
:label="$t('tls.certPath')"
hide-details
v-model="inTls.certificate_path">
</v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
:label="$t('tls.keyPath')"
hide-details
v-model="inTls.key_path">
</v-text-field>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12">
<v-textarea
:label="$t('tls.cert')"
hide-details
v-model="certText">
</v-textarea>
</v-col>
<v-col cols="12">
<v-textarea
:label="$t('tls.key')"
hide-details
v-model="keyText">
</v-textarea>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('tls.disableSni')" v-model="disableSni" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('tls.insecure')" v-model="insecure" hide-details></v-switch>
</v-col>
</v-row>
</template>
<template v-if="outTls.reality && inTls.reality">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('types.shdwTls.hs')"
hide-details
v-model="inTls.reality.handshake.server">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('out.port')"
type="number"
min="0"
hide-details
v-model="server_port">
</v-text-field>
</v-col>
<v-spacer></v-spacer>
<v-col cols="auto">
<v-btn
variant="tonal"
density="compact"
icon="mdi-key-star"
@click="genRealityKey"
:loading="loading">
<v-icon />
<v-tooltip activator="parent" location="top">
{{ $t('actions.generate') }}
</v-tooltip>
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-text-field
:label="$t('tls.privKey')"
hide-details
v-model="inTls.reality.private_key">
</v-text-field>
</v-col>
<v-col cols="12">
<v-text-field
:label="$t('tls.pubKey')"
hide-details
v-model="outTls.reality.public_key">
</v-text-field>
</v-col>
<v-col cols="12">
<v-text-field
label="Short IDs"
hide-details
append-icon="mdi-refresh"
@click:append="randomSID"
v-model="short_id">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionTime">
<v-text-field
label="Max Time Diference"
type="number"
min="1"
:suffix="$t('date.m')"
hide-details
v-model="max_time">
</v-text-field>
</v-col>
</v-row>
</template>
<v-row v-if="optionStore || optionKtls">
<v-col cols="12" sm="6" md="4" v-if="optionStore">
<v-select
hide-details
:label="$t('tls.store')"
:items="storeItems"
v-model="inTls.store">
</v-select>
</v-col>
<template v-if="optionKtls">
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('tls.kernelTx')" v-model="inTls.kernel_tx" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('tls.kernelRx')" v-model="inTls.kernel_rx" hide-details></v-switch>
</v-col>
</template>
</v-row>
<v-row v-if="outTls.utls != undefined">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
label="Fingerprint"
:items="fingerprints"
v-model="outTls.utls.fingerprint">
</v-select>
</v-col>
</v-row>
<v-card-actions>
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('tls.options') }}</v-btn>
</template>
<v-card>
<v-list>
<template v-if="tlsType == 0">
<v-list-item>
<v-switch v-model="optionSNI" color="primary" label="SNI" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionALPN" color="primary" label="ALPN" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionMinV" color="primary" :label="$t('tls.minVer')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionMaxV" color="primary" :label="$t('tls.maxVer')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionCS" color="primary" :label="$t('tls.cs')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionFP" color="primary" label="UTLS" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionStore" color="primary" :label="$t('tls.store')" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionKtls" color="primary" :label="$t('tls.ktls')" hide-details></v-switch>
</v-list-item>
</template>
<template v-else>
<v-list-item>
<v-switch v-model="optionTime" color="primary" label="Max Time Difference" hide-details></v-switch>
</v-list-item>
</template>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
<AcmeVue :tls="inTls" />
<EchVue :iTls="inTls" :oTls="outTls" />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="primary"
variant="tonal"
:loading="loading"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { tls, iTls, defaultInTls, oTls, defaultOutTls } from '@/types/tls'
import AcmeVue from '@/components/tls/Acme.vue'
import EchVue from '@/components/tls/Ech.vue'
import HttpUtils from '@/plugins/httputil'
import { push } from 'notivue'
import { i18n } from '@/locales'
import RandomUtil from '@/plugins/randomUtil'
export default {
props: ['visible', 'data', 'id'],
emits: ['close', 'save'],
data() {
return {
tls: <tls>{ id: 0, name: '', server: <iTls>{ enabled: true }, client: <oTls>{} },
title: "add",
loading: false,
menu: false,
tlsType: 0,
usePath: 0,
alpn: [
{ title: "H3", value: 'h3' },
{ title: "H2", value: 'h2' },
{ title: "Http/1.1", value: 'http/1.1' },
],
tlsVersions: [ '1.0', '1.1', '1.2', '1.3' ],
cipher_suites: [
{ title: "RSA-AES128-CBC-SHA", value: "TLS_RSA_WITH_AES_128_CBC_SHA" },
{ title: "RSA-AES256-CBC-SHA", value: "TLS_RSA_WITH_AES_256_CBC_SHA" },
{ title: "RSA-AES128-GCM-SHA256", value: "TLS_RSA_WITH_AES_128_GCM_SHA256" },
{ title: "RSA-AES256-GCM-SHA384", value: "TLS_RSA_WITH_AES_256_GCM_SHA384" },
{ title: "AES128-GCM-SHA256", value: "TLS_AES_128_GCM_SHA256" },
{ title: "AES256-GCM-SHA384", value: "TLS_AES_256_GCM_SHA384" },
{ title: "CHACHA20-POLY1305-SHA256", value: "TLS_CHACHA20_POLY1305_SHA256" },
{ title: "ECDHE-ECDSA-AES128-CBC-SHA", value: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA" },
{ title: "ECDHE-ECDSA-AES256-CBC-SHA", value: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA" },
{ title: "ECDHE-RSA-AES128-CBC-SHA", value: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA" },
{ title: "ECDHE-RSA-AES256-CBC-SHA", value: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA" },
{ title: "ECDHE-ECDSA-AES128-GCM-SHA256", value: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" },
{ title: "ECDHE-ECDSA-AES256-GCM-SHA384", value: "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" },
{ title: "ECDHE-RSA-AES128-GCM-SHA256", value: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" },
{ title: "ECDHE-RSA-AES256-GCM-SHA384", value: "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" },
{ title: "ECDHE-ECDSA-CHACHA20-POLY1305-SHA256", value: "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256" },
{ title: "ECDHE-RSA-CHACHA20-POLY1305-SHA256", value: "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256" }
],
storeItems: [
{ title: "Mozilla", value: "mozilla" },
{ title: "Chrome", value: "chrome" },
],
fingerprints: [
{ title: "Chrome", value: "chrome" },
{ title: "Firefox", value: "firefox" },
{ title: "Microsoft Edge", value: "edge" },
{ title: "Apple Safari", value: "safari" },
{ title: "360", value: "360" },
{ title: "QQ", value: "qq" },
{ title: "Apple IOS", value: "ios" },
{ title: "Android", value: "android" },
{ title: "Random", value: "random" },
{ title: "Randomized", value: "randomized" },
]
}
},
methods: {
updateData(id: number) {
if (id > 0) {
const newData = <tls>JSON.parse(this.$props.data)
this.tls = newData
if (this.tls.server == null) this.tls.server = { enabled: true }
if (this.tls.client == null) this.tls.client = {}
this.tlsType = newData.server?.reality == undefined ? 0 : 1
this.usePath = newData.server?.key == undefined ? 0 : 1
this.title = "edit"
}
else {
this.tls = <tls>{ id: 0, name: '', server: {enabled: true}, client: {} }
this.tlsType = 0
this.usePath = 0
this.title = "add"
}
},
changeTlsType(){
if (this.tlsType) {
this.tls.server = <iTls>{
enabled: true,
reality: { enabled: true, handshake: { server_port: 443 }, short_id: RandomUtil.randomShortId() },
server_name: ""
}
this.tls.client = <oTls>{ reality: { public_key: "" }, utls: defaultOutTls.utls }
} else {
this.tls.server = <iTls>{ enabled: true }
this.tls.client = <oTls>{}
}
},
closeModal() {
this.updateData(0) // reset
this.$emit('close')
},
saveChanges() {
this.loading = true
this.$emit('save', this.tls)
this.loading = false
},
async genSelfSigned(){
this.loading = true
const msg = await HttpUtils.get('api/keypairs', { k: "tls", o: this.inTls.server_name?? "''" })
this.loading = false
if (msg.success) {
this.inTls.key_path=undefined
this.inTls.certificate_path=undefined
this.usePath = 1
if (msg.obj.length>0){
let privateKey = <string[]>[]
let publicKey = <string[]>[]
let isPrivateKey = false
let isPublicKey = false
msg.obj.forEach((line:string) => {
if (line === "-----BEGIN PRIVATE KEY-----") {
isPrivateKey = true
isPublicKey = false
privateKey.push(line)
} else if (line === "-----END PRIVATE KEY-----") {
isPrivateKey = false
privateKey.push(line)
} else if (line === "-----BEGIN CERTIFICATE-----") {
isPublicKey = true
isPrivateKey = false
publicKey.push(line)
} else if (line === "-----END CERTIFICATE-----") {
isPublicKey = false
publicKey.push(line)
} else if (isPrivateKey) {
privateKey.push(line)
} else if (isPublicKey) {
publicKey.push(line)
}
})
this.inTls.key = privateKey?? undefined
this.inTls.certificate = publicKey?? undefined
} else {
push.error({
message: i18n.global.t('error') + ": " + msg.obj
})
}
}
},
async genRealityKey(){
this.loading = true
const msg = await HttpUtils.get('api/keypairs', { k: "reality" })
this.loading = false
if (msg.success) {
msg.obj.forEach((line:string) => {
if (this.inTls.reality && this.outTls.reality){
if (line.startsWith("PrivateKey")){
this.inTls.reality.private_key = line.substring(12)
}
if (line.startsWith("PublicKey")){
this.outTls.reality.public_key = line.substring(11)
}
}
})
} else {
push.error({
message: i18n.global.t('error') + ": " + msg.obj
})
}
},
randomSID(){
this.short_id = RandomUtil.randomShortId().join(',')
}
},
computed: {
inTls(): iTls {
return this.tls.server
},
outTls(): oTls {
return this.tls.client
},
certText: {
get(): string { return this.inTls.certificate ? this.inTls.certificate.join('\n') : '' },
set(v:string) { this.inTls.certificate = v.split('\n') }
},
keyText: {
get(): string { return this.inTls.key ? this.inTls.key.join('\n') : '' },
set(v:string) { this.inTls.key = v.split('\n') }
},
disableSni: {
get() { return this.outTls.disable_sni ?? false },
set(v: boolean) { this.tls.client.disable_sni = v ? true : undefined }
},
insecure: {
get() { return this.outTls.insecure ?? false },
set(v: boolean) { this.tls.client.insecure = v ? true : undefined }
},
server_port: {
get() { return this.inTls.reality?.handshake?.server_port ? this.inTls.reality.handshake.server_port : 443 },
set(v: any) {
if (this.inTls.reality){
this.inTls.reality.handshake.server_port = v.length == 0 || v == 0 ? 443 : parseInt(v)
}
}
},
short_id: {
get() { return this.inTls.reality?.short_id ? this.inTls.reality.short_id.join(',') : undefined },
set(v: string) {
if (this.inTls.reality){
this.inTls.reality.short_id = v.length > 0 ? v.split(',') : []
}
}
},
max_time: {
get() { return this.inTls?.reality?.max_time_difference ? this.inTls.reality.max_time_difference.replace('m','') : 1 },
set(v: number) {
if (this.inTls.reality){
this.inTls.reality.max_time_difference = v > 0 ? v + 'm' : '1m'
}
}
},
optionSNI: {
get(): boolean { return this.inTls.server_name != undefined },
set(v:boolean) { this.inTls.server_name = v ? '' : undefined }
},
optionALPN: {
get(): boolean { return this.inTls.alpn != undefined },
set(v:boolean) { this.inTls.alpn = v ? defaultInTls.alpn : undefined }
},
optionMinV: {
get(): boolean { return this.inTls.min_version != undefined },
set(v:boolean) { this.inTls.min_version = v ? defaultInTls.min_version : undefined }
},
optionMaxV: {
get(): boolean { return this.inTls.max_version != undefined },
set(v:boolean) { this.inTls.max_version = v ? defaultInTls.max_version : undefined }
},
optionCS: {
get(): boolean { return this.inTls.cipher_suites != undefined },
set(v:boolean) { this.inTls.cipher_suites = v ? defaultInTls.cipher_suites : undefined }
},
optionFP: {
get(): boolean { return this.outTls.utls != undefined },
set(v:boolean) { this.outTls.utls = v ? defaultOutTls.utls : undefined }
},
optionStore: {
get(): boolean { return this.inTls.store != undefined },
set(v:boolean) { this.inTls.store = v ? 'mozilla' : undefined }
},
optionKtls: {
get(): boolean { return this.inTls.kernel_tx != undefined || this.inTls.kernel_rx != undefined },
set(v:boolean) {
if (v) {
this.inTls.kernel_tx = false
this.inTls.kernel_rx = false
} else {
delete this.inTls.kernel_tx
delete this.inTls.kernel_rx
}
}
},
optionEch: {
get(): boolean { return this.outTls.ech != undefined },
set(v:boolean) { this.outTls.ech = v ? defaultOutTls.ech : undefined }
},
optionTime: {
get(): boolean { return this.inTls?.reality?.max_time_difference != undefined },
set(v:boolean) { if (this.inTls.reality) this.inTls.reality.max_time_difference = v ? "1m" : undefined }
}
},
watch: {
visible(v) {
if (v) {
this.updateData(this.$props.id)
}
},
},
components: { AcmeVue, EchVue }
}
</script>
+258
View File
@@ -0,0 +1,258 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg" :loading="loading">
<v-card-title>
<v-row>
<v-col>{{ $t('admin.api.title') }}</v-col>
<v-spacer></v-spacer>
<v-col cols="auto"><v-icon icon="mdi-close-box" @click="$emit('close')" /></v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-alert
v-if="newToken.token.length>0"
color="success"
density="compact"
icon="mdi-alert-circle-outline"
>
{{ $t('admin.api.msg') }}
<v-text-field
readonly
variant="outlined"
bg-color="warning"
append-inner-icon="mdi-content-copy"
@click:append-inner="copyToClipboard(newToken.token)"
v-model="newToken.token"
></v-text-field>
</v-alert>
<v-table density="compact">
<thead>
<tr>
<th>#</th>
<th>{{ $t('admin.api.token') }}</th>
<th>{{ $t('client.desc') }}</th>
<th>{{ $t('date.expiry') }}</th>
<th>{{ $t('actions.del') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(token, index) of tokens" :key="token.id">
<td>{{ token.id }}</td>
<td>{{ token.token }}</td>
<td>{{ token.desc }}</td>
<td>{{ dateFormatted(token.expiry) }}</td>
<td>
<v-menu
v-model="delOverlay[index]"
:close-on-content-click="false"
location="top center"
>
<template v-slot:activator="{ props }">
<v-icon
class="me-2"
color="error"
v-bind="props"
>
mdi-delete
</v-icon>
</template>
<v-card :title="$t('actions.del')" rounded="lg">
<v-divider></v-divider>
<v-card-text>{{ $t('confirm') }}</v-card-text>
<v-card-actions>
<v-btn color="error" variant="outlined" @click="deleteToken(token.id)">{{ $t('yes') }}</v-btn>
<v-btn color="success" variant="outlined" @click="delOverlay[index] = false">{{ $t('no') }}</v-btn>
</v-card-actions>
</v-card>
</v-menu>
</td>
</tr>
</tbody>
</v-table>
<v-btn color="primary" @click="showAddToken()">
{{ $t('actions.add') }}
</v-btn>
<v-dialog v-model="showNewToken" width="300">
<v-card class="rounded-lg">
<v-card-title>
<v-row>
<v-col>{{ $t('admin.api.token') }}</v-col>
<v-spacer></v-spacer>
<v-col cols="auto"><v-icon icon="mdi-close-box" @click="showNewToken = false" /></v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col>
<v-text-field :label="$t('client.desc')" v-model="newToken.desc"></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field :label="$t('date.expiry')" v-model.number="newToken.expiry" min="0" type="number" :suffix="$t('date.d')"></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="blue-darken-1"
variant="outlined"
@click="showNewToken = false"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="blue-darken-1"
variant="tonal"
@click="addToken"
>
{{ $t('actions.add') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="blue-darken-1"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { i18n } from '@/locales'
import HttpUtils from '@/plugins/httputil'
import Clipboard from 'clipboard'
import { push } from 'notivue';
export default {
props: ['visible', 'user'],
data() {
return {
loading: false,
tokens: <any[]>[],
showNewToken: false,
newToken: {
desc: '',
token: '',
expiry: 0,
},
delOverlay: new Array<boolean>(0),
}
},
computed: {
locale() {
const l = i18n.global.locale.value
switch (l) {
case "zhHans":
return "zh-cn"
case "zhHant":
return "zh-tw"
default:
return l
}
},
},
methods: {
async loadData() {
this.loading = true
const data = await HttpUtils.get('api/tokens')
if (data.success) {
this.tokens = data.obj ?? []
this.delOverlay = new Array<boolean>(this.tokens.length).fill(false)
}
this.loading = false
},
resetNewToken() {
this.newToken={
desc: '',
token: '',
expiry: 30,
}
},
showAddToken() {
this.resetNewToken()
this.showNewToken = true
},
async addToken() {
this.loading = true
this.newToken.expiry = this.newToken.expiry>0 ? this.newToken.expiry : 0
const response = await HttpUtils.post('api/addToken', { desc: this.newToken.desc, expiry: this.newToken.expiry })
if (response.success) {
this.newToken.token = response.obj
this.loadData()
this.showNewToken = false
}
this.loading = false
},
async deleteToken(id: number) {
this.loading = true
const response = await HttpUtils.post('api/deleteToken', { id: id })
if (response.success) {
this.loadData()
}
this.loading = false
},
dateFormatted(expiry: number) {
if (expiry == 0) return i18n.global.t('unlimited')
const date = new Date(expiry*1000)
return date.toLocaleString(this.locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
},
copyToClipboard(txt:string) {
const hiddenButton = document.createElement('button')
hiddenButton.className = 'clipboard-btn'
document.body.appendChild(hiddenButton)
const clipboard = new Clipboard('.clipboard-btn', {
text: () => txt,
container: document.getElementById('qrcode-modal')?? undefined
});
clipboard.on('success', () => {
clipboard.destroy()
push.success({
message: i18n.global.t('success') + ": " + i18n.global.t('copyToClipboard'),
duration: 5000,
})
})
clipboard.on('error', () => {
clipboard.destroy()
push.error({
message: i18n.global.t('failed') + ": " + i18n.global.t('copyToClipboard'),
duration: 5000,
})
})
// Perform click on hidden button to trigger copy
hiddenButton.click()
document.body.removeChild(hiddenButton)
},
closeModal() {
this.$emit('close')
},
},
watch: {
visible(v) {
if (v) {
this.resetNewToken()
this.loadData()
}
},
},
}
</script>
+101
View File
@@ -0,0 +1,101 @@
<template>
<v-dialog :model-value="visible" @update:model-value="$emit('update:visible', $event)" transition="dialog-bottom-transition" width="90%" max-width="400">
<v-card class="rounded-lg" :loading="loading">
<v-card-title>
<v-row>
<v-col>{{ $t('main.stats.title') }}</v-col>
<v-spacer></v-spacer>
<v-col cols="auto">
<v-icon icon="mdi-refresh" class="me-2" @click="refresh" v-tooltip:top="$t('actions.update')" />
<v-icon icon="mdi-close" @click="$emit('update:visible', false)" />
</v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-table density="compact">
<tbody>
<tr v-for="row in tableRows" :key="row.key">
<td class="pa-2" style="width: 40px;">
<v-icon :icon="row.icon" size="small" :color="row.color || undefined" />
</td>
<td class="pa-2">{{ row.label }}</td>
<td class="pa-2 text-end" style="direction: ltr;">{{ row.value }}</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { computed, ref, watch } from 'vue'
import HttpUtils from '@/plugins/httputil'
import { HumanReadable } from '@/plugins/utils'
import { i18n } from '@/locales'
export default {
props: {
visible: { type: Boolean, default: false },
},
emits: ['update:visible'],
setup(props) {
const loading = ref(false)
const info = ref<{
clients?: number
inbounds?: number
outbounds?: number
services?: number
endpoints?: number
clientUp?: number
clientDown?: number
}>({})
const clientUp = computed(() => HumanReadable.sizeFormat(info.value.clientUp ?? 0))
const clientDown = computed(() => HumanReadable.sizeFormat(info.value.clientDown ?? 0))
const totalUsage = computed(() => {
const up = info.value.clientUp ?? 0
const down = info.value.clientDown ?? 0
return HumanReadable.sizeFormat(up + down)
})
const tableRows = computed(() => {
const t = (key: string) => i18n.global.t(key)
return [
{ key: 'clients', icon: 'mdi-account-multiple', label: t('pages.clients'), value: info.value.clients ?? 0, color: undefined },
{ key: 'inbounds', icon: 'mdi-cloud-download', label: t('pages.inbounds'), value: info.value.inbounds ?? 0, color: undefined },
{ key: 'outbounds', icon: 'mdi-cloud-upload', label: t('pages.outbounds'), value: info.value.outbounds ?? 0, color: undefined },
{ key: 'services', icon: 'mdi-server', label: t('pages.services'), value: info.value.services ?? 0, color: undefined },
{ key: 'endpoints', icon: 'mdi-cloud-tags', label: t('pages.endpoints'), value: info.value.endpoints ?? 0, color: undefined },
{ key: 'clientUp', icon: 'mdi-cloud-upload', label: t('stats.upload'), value: clientUp.value, color: 'orange' },
{ key: 'clientDown', icon: 'mdi-cloud-download', label: t('stats.download'), value: clientDown.value, color: 'success' },
{ key: 'totalUsage', icon: 'mdi-chart-box', label: t('main.stats.totalUsage'), value: totalUsage.value, color: 'primary' },
]
})
const refresh = async () => {
loading.value = true
const data = await HttpUtils.get('api/status', { r: 'db' })
if (data.success && data.obj) {
info.value = data.obj.db ?? data.obj
}
loading.value = false
}
watch(() => props.visible, (v) => {
if (v) refresh()
})
return {
loading,
info,
clientUp,
clientDown,
totalUsage,
tableRows,
refresh,
}
},
}
</script>
+129
View File
@@ -0,0 +1,129 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="400">
<v-card class="rounded-lg" id="qrcode-modal" :loading="loading">
<v-card-title>
<v-row>
<v-col>Wireguard QrCode</v-col>
<v-spacer></v-spacer>
<v-col cols="auto"><v-icon icon="mdi-close-box" @click="$emit('close')" /></v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-row v-for="l, i in wgLinks">
<v-col style="text-align: center;" v-if="l.length>0">
<v-chip>{{ $t('types.wg.peer') + ' ' + (i+1) }}</v-chip> <v-icon icon="mdi-download" @click="download(l,i)" /><br />
<QrcodeVue :value="l" :size="size" @click="copyToClipboard(l)" :margin="1" style="border-radius: .5rem; cursor: copy;" />
</v-col>
</v-row>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import QrcodeVue from 'qrcode.vue'
import Clipboard from 'clipboard'
import { i18n } from '@/locales'
import { push } from 'notivue'
export default {
props: ['data', 'visible'],
data() {
return {
wgData: <any>{},
wgLinks: <string[]>[],
loading: false,
}
},
methods: {
async load() {
this.wgData = this.$props.data
this.wgLinks = []
const address = location.hostname
this.wgData.peers.forEach((_: any, index: number) => {
this.wgLinks.push(this.getWireguardLink(index, address))
})
},
getWireguardLink(peerId: number, address: string) {
const peerData = this.wgData.peers[peerId]
const keys = this.wgData.ext?.keys?.find((key: any) => key.public_key == peerData.public_key)
if (!keys || !this.wgData.ext?.public_key) return ''
let txt = `[Interface]\n`
txt += `PrivateKey = ${keys.private_key}\n`
txt += `Address = ${peerData.allowed_ips.join(',')}\n`
txt += `DNS = ${this.wgData.ext?.dns?.length>0 ? this.wgData.ext.dns : '1.1.1.1, 9.9.9.9'}\n`
if (this.wgData.mtu) {
txt += `MTU = ${this.wgData.mtu}\n`
}
txt += `\n# ${this.wgData.tag} - ${peerId}\n`
txt += `[Peer]\n`
txt += `PublicKey = ${this.wgData.ext.public_key}\n`
txt += `AllowedIPs = 0.0.0.0/0, ::/0\n`
txt += `Endpoint = ${address}:${this.wgData.listen_port}\n`
if (peerData.pre_shared_key) {
txt += `\nPresharedKey = ${peerData.pre_shared_key}`
}
if (peerData.persistent_keepalive_interval) {
txt += `\nPersistentKeepalive = ${peerData.persistent_keepalive_interval}\n`
}
return txt;
},
copyToClipboard(txt:string) {
const hiddenButton = document.createElement('button')
hiddenButton.className = 'clipboard-btn'
document.body.appendChild(hiddenButton)
const clipboard = new Clipboard('.clipboard-btn', {
text: () => txt,
container: document.getElementById('qrcode-modal')?? undefined
});
clipboard.on('success', () => {
clipboard.destroy()
push.success({
message: i18n.global.t('success') + ": " + i18n.global.t('copyToClipboard'),
duration: 5000,
})
})
clipboard.on('error', () => {
clipboard.destroy()
push.error({
message: i18n.global.t('failed') + ": " + i18n.global.t('copyToClipboard'),
duration: 5000,
})
})
// Perform click on hidden button to trigger copy
hiddenButton.click()
document.body.removeChild(hiddenButton)
},
download(text: string, i: number) {
let filename = this.wgData.tag + '_peer_' + (i+1) + '.conf';
let element = document.createElement('a');
element.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(text));
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
},
computed: {
size() {
if (window.innerWidth > 380) return 300
if (window.innerWidth > 330) return 280
return 250
}
},
watch: {
visible(v) {
if (v) {
this.load()
}
},
},
components: { QrcodeVue }
}
</script>
+637
View File
@@ -0,0 +1,637 @@
export default {
success: "success",
failed: "failed",
enable: "Enable",
disable: "Disable",
none: "None",
all: "All",
loading: "Loading...",
confirm: "Are you sure ?",
yes: "yes",
no: "no",
unlimited: "infinite",
type: "Type",
protocol: "Protocol",
submit: "Submit",
reset: "Reset",
now: "Now",
network: "Network",
copyToClipboard: "Copy to clipboard",
noData: "No data!",
invalidLogin: "Invalid Login!",
online: "Online",
version: "Version",
email: "Email",
commaSeparated: "(comma separated)",
count: "Count",
template: "Template",
editor: "Editor",
error: {
dplData: "Duplicate Data",
core: "Sing-Box Error",
invalidData: "Invalid Data",
},
theme: {
light: "Light",
dark: "Dark",
system: "System",
},
pages: {
login: "Login",
home: "Home",
inbounds: "Inbounds",
outbounds: "Outbounds",
services: "Services",
endpoints: "Endpoints",
clients: "Clients",
rules: "Rules",
tls: "TLS Settings",
basics: "Basics",
dns: "DNS",
admins: "Admins",
settings: "Settings",
},
main: {
tiles: "Tiles",
gauges: "Gauges",
charts: "Charts",
infos: "Information",
gauge: {
cpu: "CPU Gauge",
mem: "RAM Gauge",
dsk: "Disk Gauge",
swp: "Swap Gauge",
},
chart: {
cpu: "CPU Monitor",
mem: "RAM Monitor",
net: "Network Bandwidth",
pnet: "Network Packets",
dio: "Disk I/O",
},
info: {
sys: "System Info",
sbd: "Sing-Box Info",
host: "Host",
cpu: "CPU",
core: "Core",
uptime: "Uptime",
startupTime: "Startup time",
threads: "Threads",
memory: "Memory",
running: "Running"
},
backup: {
title: "Backup & Restore",
backup: "Download Backup",
restore: "Restore Backup",
exclStats: "Exclude graphs",
exclChanges: "Exclude changes",
sbConfig: "Download Sing-Box Config",
},
stats: {
title: "Usage & Counts",
totalUsage: "Total Usage",
},
},
objects: {
inbound: "Inbound",
client: "Client",
outbound: "Outbound",
endpoint: "Endpoint",
config: "Config",
rule: "Rule",
ruleset: "Ruleset",
service: "Service",
dnsserver: "DNS Server",
dnsrule: "DNS Rule",
user: "User",
tag: "Tag",
listen: "Listen",
dial: "Dial",
tls: "TLS",
multiplex: "Multiplex",
transport: "Transport",
headers: "Headers",
key: "Key",
value: "Value",
},
actions: {
action: "Action",
add: "Add",
addbulk: "Add Bulk",
editbulk: "Edit Bulk",
delbulk: "Delete Bulk",
new: "New",
edit: "Edit",
del: "Delete",
clone: "Clone",
test: "Test",
testAll: "Test all",
save: "Save",
update: "Update",
submit: "Submit",
set: "Set",
generate: "Generate",
disable: "Disable",
close: "Close",
restartApp: "Restart App",
restartSb: "Restart Singbox",
},
login: {
title: "Login",
username: "Username",
unRules: "Username can not be empty",
password: "Password",
pwRules: "Password can not be empty",
},
menu: {
logout: "Logout",
},
admin: {
changeCred: "Change credentials",
oldPass: "Current Password",
newUname: "New Username",
newPass: "New Password",
lastLogin: "Last login",
date: "Date",
time: "Time",
changes: "Changes",
actor: "Actor",
key: "Key",
action: "Action",
api: {
title: "API Tokens",
msg: "Please copy the token below and store it somewhere safe. It will not be shown again.",
token: "Token",
},
},
setting: {
interface: "Interface",
sub: "Subscription",
addr: "Address",
port: "Port",
webPath: "Base URI",
domain: "Domain",
sslKey: "SSL Key Path",
sslCert: "SSL Certificate Path",
webUri: "Panel URI",
sessionAge: "Session Maximum Age",
trafficAge: "Traffic Maximum Age",
timeLoc: "Timezone Location",
subEncode: "Enable Encoding",
subInfo: "Enable Client Info",
path: "Default Path",
update: "Automatic Update Time",
subUri: "Subscription URI",
jsonSub: "JSON Subscription",
toDirect: "Route to Direct",
toBlock: "Route to Block",
timestamp: "Timestamp",
globalDns: "Global DNS",
directDns: "Direct DNS",
toDirectDns: "Route to Direct DNS",
jsonSubOptions: "Other Options",
excludePkg: "Exclude Packages",
clashSub: "Clash Subscription",
mixedPort: "Mixed Inbound Port",
tun: "Tun Inbound",
},
client: {
name: "Name",
desc: "Description",
group: "Group",
inboundTags: "Inbound Tags",
basics: "Basics",
config: "Config",
links: "Links",
external: "External Link",
sub: "External Subscription",
delayStart: "Delay Start",
autoReset: "Auto Reset",
resetDays: "Reset Days",
nextReset: "Next Reset",
},
bulk: {
order: "Order",
random: "Random",
changeLimits: "Change limits",
addInbounds: "Add inbounds",
removeInbounds: "Remove inbounds",
addDays: "Add days",
addVolume: "Add volume",
},
types: {
un: "Username",
pw: "Password",
direct: {
overrideAddr: "Override Address",
overridePort: "Override Port",
},
hy: {
obfs: "Obfuscated Password",
auth: "Authentication Password",
hyOptions: "Hysteria Options",
hy2Options: "Hysteria2 Options",
ignoreBw: "Ignore Client Bandwidth",
},
shdwTls: {
hs: "Handshake Server",
addHS: "Add Handshake Server",
},
ssh: {
passphrase: "Passphrase",
hostKey: "Host Keys",
algorithm: "Key Algorithms",
clientVer: "Client Version",
options: "SSH Options",
},
tor: {
execPath: "Executable File Path",
dataDir: "Data Directory",
extArgs: "Extra Args",
},
tuic: {
congControl: "Congestion Control",
authTimeout: "Authentication Timeout",
hb: "Heartbeat",
},
tun: {
addr: "Addresses",
ifName: "Interface Name",
excludeMptcp: "Exclude MPTCP",
fallbackRuleIndex: "iproute2 Fallback Rule Index",
},
vless: {
flow: "Flow",
udpEnc: "UDP Packet Encoding",
},
vmess: {
security: "Security",
globalPadding: "Global Padding",
authLen: "Encryptrd Length",
},
wg: {
privKey: "Private Key",
pubKey: "Peer Public Key",
psk: "Pre-Shared Key",
localIp: "Local IPs",
worker: "Workers",
ifName: "Interface Name",
sysIf: "System Interface",
options: "Wireguard Options",
allowedIp: "Allowed IPs",
peer: "Peer",
peers: "Peers",
},
lb: {
defaultOut: "Default Outbound",
interruptConn: "Interrupt exist connections",
testUrl: "Test URL",
interval: "Interval",
tolerance: "Tolerance",
urlTestOptions: "URLTest Options"
},
ts: {
options: "Tailscale Options",
stateDir: "State Directory",
authKey: "Authentication Key",
relayServer: "Relay Server",
relayServerPort: "Relay Server Port",
relayEndpoints: "Relay Static Endpoints",
systemInterface: "System Interface",
sysIfName: "Interface Name",
sysIfMtu: "Interface MTU",
controlUrl: "Control URL",
ephemeral: "Ephemeral Mode",
hostname: "Hostname",
acceptRoutes: "Accept Routes",
exitNode: "Exit Node",
allowLanAccess: "Allow LAN Access",
advRoutes: "Advertise Routes",
advExitNode: "Advertise Exit Node",
udpTimeout: "UDP Timeout",
},
ocm: {
credentialPath: "Credential Path",
usagesPath: "Usages Path",
users: "Users",
userName: "Name",
userToken: "Token",
},
ccm: {
credentialPath: "Credential Path",
usagesPath: "Usages Path",
users: "Users",
userName: "Name",
userToken: "Token",
},
derp: {
configPath: "Config Path",
verifyClientEndpoint: "Verify Client Endpoint",
verifyClientUrl: "Verify Client URL",
meshWith: "Mesh With",
meshPsk: "Mesh PSK",
meshPskFile: "Mesh PSK File",
stun: "STUN Server",
options: "DERP Options",
},
anytls: {
idleInterval: "Idle Session Check Interval",
idleTimeout: "Idle Session Timeout",
minIdle: "Minimum Idle Session",
},
naive: {
insecureConcurrency: "Insecure Concurrency",
quic: "QUIC",
quicCongestion: "QUIC Congestion Control",
udpOverTcp: "UDP over TCP",
},
},
in: {
addr: "Address",
port: "Port",
ssMethod: "Method",
ssManageable: "Manageable",
sSide: "Server Side",
cSide: "Client Side",
multiDomain: "Multi Domain",
remark: "Remark",
mdOption: "Multi Domain Options",
},
listen: {
options: "Listen Options",
tcpOptions: "TCP Options",
udpOptions: "UDP Options",
detour: "Detour",
detourText: "Forward to inbound",
disableTcpKeepAlive: "Disable TCP Keep Alive",
tcpKeepAlive: "TCP Keep Alive",
tcpKeepAliveInterval: "TCP Keep Alive Interval",
},
dial: {
bindIf: "Bind to Network Interface",
bindIp4: "Bind to IPv4",
bindIp6: "Bind to IPv6",
bindNoPort: "Bind Address No Port",
reuseAddr: "Reuse Listener Address",
connTimeout: "Connection Timeout",
disableTcpKeepAlive: "Disable TCP Keep Alive",
tcpKeepAlive: "TCP Keep Alive",
tcpKeepAliveInterval: "TCP Keep Alive Interval",
domainResolver: "Domain Resolver",
options: "Dial Options",
detourText: "Forward to outbound",
},
transport: {
enable: "Enable Transport",
host: "Host",
hosts: "Hosts",
path: "Path",
httpMethod: "Request Method",
idleTimeout: "Idle Timeout",
pingTimeout: "Ping Timeout",
grpcServiceName: "Service Name",
grpcPws: "Permit Without Stream",
},
mux: {
enable: "Enable Multiplex",
maxConn: "Max Connections",
minStr: "Min Streams",
maxStr: "Max Streams",
padding: "Only padding",
enableBrutal: "Enable Brutal",
},
out: {
addr: "Server Address",
port: "Server Port",
addUrlTest: "Add URLTest",
delay: "Delay",
},
rule: {
add: "Add Rule",
simple: "Simple",
logical: "Logical",
mode: "Mode",
invert: "Invert",
ipVer: "IP Version",
domain: "Domains",
domainSufix: "Domain Suffixes",
domainKw: "Domain Keywords",
domainRgx: "Domain Regexes",
ip: "IP CIDRs",
privateIp: "Invalid IP Ranges",
port: "Ports",
portRange: "Port Ranges",
srcCidr: "Source IP CIDRs",
srcPrivateIp: "Invalid Source IPs",
srcPort: "Source Ports",
srcPortRange: "Source Port Ranges",
ruleset: "Rulesets",
rulesetMatchSrc: "Ruleset IPcidr Match Source",
preferredBy: "Preferred By (Outbound)",
interfaceAddr: "Interface Address",
options: "Rule Options",
domainRules: "Domain/IP",
srcIpRules: "Source IP",
srcPortRules: "Source Port",
udpDisableDomainUnmapping: "UDP Disable Domain Unmapping",
udpConnect: "UDP Connect",
udpTimeout: "UDP Timeout",
method: "Method",
noDrop: "No Drop",
sniffer: "Sniffer",
timeout: "Timeout",
strategy: "Strategy",
etaHint: "Enter one item per line. Blank lines and duplicate entries will be skipped.",
import: {
title: "Mass import of rulesets",
rulesTitle: "Importing rules",
urlsHint: "One URL per line. The tag is determined from the file name without the extension.",
fileHint: "Upload it .a txt file with URLs, one per line.",
jsonHint: "Insert a JSON object with the rules and/or rule_set arrays. You can insert the entire \"route\" block: {'{'}...{'}'} or just its contents.",
fileJsonHint: "Upload it .a json file with the route block.",
urlHint: "Specify a direct link to the JSON file (for example, a raw GitHub link).",
preview: "Preview",
skipped: "already exist, shown in gray",
conflict: "Conflicts detected",
merge: "Merge - add imported rules (skip duplicate set tags)",
replace: "Replace - delete existing rules and sets, and import them again",
pasteUrls: "Insert URL",
uploadTxt: "Upload .txt",
uploadFile: "Upload a file",
fromUrl: "By URL",
selectTxt: "Choose .txt file",
selectJson: "Choose .json file",
parse: "Parse",
conflictDesc: "The config already has {rules} of rules and {rulesets} of rule sets. Select an action:",
finalOutbound: "Outbound by default (final)",
applyFinal: "Apply as outbound by default",
errNoArrays: 'No "rules" or "rule_set" arrays found.',
errJsonParse: "JSON parse error: {message}",
errNoArraysFetched: 'No "rules" or "rule_set" found in fetched JSON.',
errFetch: "Fetch error: {message}",
errNoFile: "No file selected.",
errNoArraysInFile: 'No "rules" or "rule_set" found in the file.',
},
},
ruleset: {
add: "Add Ruleset",
format: "Data Format",
interval: "Update Intervals",
remote: "Remote",
local: "Local",
},
dns: {
add: "Add Dns Server",
title: "Dns Servers",
final: "Final",
server: "Server",
firstServer: "First Server",
cacheCapacity: "Cache Capacity",
disableCache: "Disable Cache",
disableExpire: "Disable Expire",
independentCache: "Independent Cache",
reverseMapping: "Reverse Mapping",
domainStrategy: "Domain Strategy",
local: {
preferGo: "Prefer Go",
},
rule: {
add: "Add Dns Rule",
title: "Dns Rules",
inet4Range: "IPv4 Range",
inet6Range: "IPv6 Range",
acceptDefault: "Accept Default Resolvers",
action: {
title: "Action",
route: "Route",
routeOptions: "Route Options",
reject: "Reject",
predefined: "Predefined",
rewriteTtl: "Rewrite TTL",
clientSubnet: "Client Subnet",
rcode: "Response Code",
rcodes: {
noError: "Ok",
formerr: "Bad request",
servFail: "Server failure",
nxDomain: "Not found",
refused: "Refused",
notImp: "Not Implemented",
},
answer: "Answers",
ns: "Nameservers",
extra: "Extra",
},
}
},
basic: {
log: {
title: "Logs",
level: "Level",
output: "Output",
timestamp: "Enable Timestamp",
},
routing: {
title: "Routing",
defaultOut: "Default Outbound",
defaultIf: "Default NIC",
defaultRm: "Default Routing Mark",
defaultDns: "Default DNS Resolver",
autoBind: "Auto Bind NIC",
},
exp: {
storeFakeIp: "Store Fake IP",
extController: "External Controller",
extUi: "External UI",
extUiDownloadUrl: "UI Download URL",
extUiDownloadDetour: "UI Download detour",
secret: "Secret",
defaultMode: "Default Mode",
allowOrigin: "Allow Origin",
allowPrivate: "Allow Private Network",
},
},
tls: {
enable: "Enable TLS",
usePath: "Use Path",
useText: "Use Text",
certPath: "Certificate File Path",
keyPath: "Key File Path",
cert: "Certificate",
key: "Key",
options: "TLS Options",
minVer: "Minimum Version",
maxVer: "Maximum Version",
cs: "Cipher suits",
privKey: "Private Key",
pubKey: "Public Key",
disableSni: "Disable SNI",
insecure: "Allow Insecure",
fragment: "Fragment",
fragmentDelay: "Fragment Fallback Delay",
recordFragment: "Multiple records Fragmentation",
store: "Root Store",
ktls: "Kernel TLS",
kernelTx: "TX",
kernelRx: "RX",
queryServerName: "ECH Query Server Name",
acme: {
options: "ACME Options",
dataDir: "Data Directory",
defaultDomain: "Default Domain",
disableChallenges: "Disable Challenges",
httpChallenge: "Disable HTTP Challenge",
tlsChallenge: "Disable TLS Challenge",
altPorts: "Alternative Ports",
altHport: "Alternative HTTP Port",
altTport: "Alternative TLS Port",
caProvider: "CA Provider",
customCa: "Custom CA Provider",
extAcc: "External Account",
dns01: "DNS01 Challenge",
dns01Provider: "DNS01 Challenge Provider",
dns01Params: {
api_token: "API Token",
zone_token: "Zone Token",
access_key_id: "Access Key ID",
access_key_secret: "Access Key Secret",
region_id: "Region ID",
security_token: "Security Token",
username: "Username",
password: "Password",
subdomain: "Subdomain",
server_url: "Server URL",
},
},
},
stats: {
upload: "Upload",
download: "Download",
volume: "Volume",
usage: "Usage",
enable: "Enable Statistics",
graphTitle: "Traffic Chart",
B: "B",
KB: "KB",
MB: "MB",
GB: "GB",
TB: "TB",
PB: "PB",
p: "p",
Kp: "Kp",
Mp: "Mp",
Gp: "Gp",
Mbps: "Mbps",
},
date: {
expiry: "Expiry",
expired: "Expired",
d: "d",
h: "h",
m: "m",
s: "s",
ms: "ms",
},
}
+635
View File
@@ -0,0 +1,635 @@
export default {
success: "موفق",
failed: "خطا",
enable: "فعال",
disable: "غیرفعال",
none: "هیچ",
all: "همه",
loading: "در حال بارگذاری...",
confirm: "آیا مطمئن هستید ؟",
yes: "بله",
no: "خیر",
unlimited: "نامحدود",
type: "مدل",
protocol: "پروتکل",
submit: "تایید",
reset: "ریست",
now: "اکنون",
network: "شبکه",
copyToClipboard: "کپی در حافظه",
noData: "بدون داده!",
invalidLogin: "ورود نامعتبر!",
online: "آنلاین",
version: "نسخه",
email: "ایمیل",
commaSeparated: "(جداشده با کاما)",
count: "تعداد",
template: "الگو",
editor: "ویرایشگر",
error: {
dplData: "داده تکراری",
core: "خطا در سینگ‌باکس",
invalidData: "داده نامعتبر",
},
theme: {
light: "روشن",
dark: "تیره",
system: "سیستم",
},
pages: {
login: "ورود",
home: "خانه",
inbounds: "ورودی‌ها",
outbounds: "خروجی‌ها",
endpoints: "درگاه‌ها",
services: "خدمات",
clients: "کاربران",
rules: "قوانین",
tls: "رمزنگاری‌ها",
basics: "ترازها",
dns: "DNS",
admins: "ادمین‌ها",
settings: "پیکربندی",
},
main: {
tiles: "کاشی‌ها",
gauges: "سنجش‌ها",
charts: "نمودارها",
infos: "داده‌ها",
gauge: {
cpu: "سنجش پردازنده",
mem: "سنجش حافظه",
dsk: "سنجش دیسک",
swp: "سنجش Swap",
},
chart: {
cpu: "نمودار پردازنده",
mem: "نمودار حافظه",
net: "ترافیک شبکه",
pnet: "بسته‌های شبکه",
dio: "نمودار دیسک",
},
info: {
sys: "داده‌های سیستم",
sbd: "داده‌های سینگ‌باکس",
host: "نام",
cpu: "پردازنده",
core: "هسته",
uptime: "مدت‌",
startupTime: "زمان راه‌اندازی",
threads: "نخ‌ها",
memory: "حافظه",
running: "اجرا"
},
backup: {
title: "پشتیبان‌گیری و بازیابی",
backup: "دریافت پشتیبان",
restore: "بازیابی پشتیبان",
exclStats: "بدون گراف‌ها",
exclChanges: "بدون تغییرات",
sbConfig: "دریافت پیکربندی سینگ‌باکس",
},
stats: {
title: "آمار و تعداد",
totalUsage: "مجموع مصرف",
},
},
objects: {
inbound: "ورودی‌",
client: "کاربر",
outbound: "خروجی‌",
endpoint: "درگاه",
config: "پیکربندی",
rule: "قانون",
ruleset: "مجموعه",
service: "خدمت",
dnsserver: "سرور DNS",
dnsrule: "قانون DNS",
user: "کاربر",
tag: "برچسب",
listen: "گوش‌دادن",
dial: "تماس",
tls: "رمزنگاری",
multiplex: "تسهیم",
transport: "انتقال",
headers: "سربرگ‌ها",
key: "نام",
value: "مقدار",
},
actions: {
action: "فرمان",
add: "ایجاد",
addbulk: "ایجاد انبوه",
editbulk: "ویرایش انبوه",
delbulk: "حذف انبوه",
new: "جدید",
edit: "ویرایش",
del: "حذف",
clone: "شبیه‌سازی",
test: "تست",
testAll: "تست همه",
save: "ذخیره",
update: "بروزرسانی",
submit: "ارسال",
set: "تنظیم",
generate: "تولید",
disable: "غیرفعال",
close: "بستن",
restartApp: "ریستارت پنل",
restartSb: "ریستارت سینگ‌باکس",
},
login: {
title: "ورود",
username: "نام کاربری",
unRules: "نام کاربری نمی‌تواند خالی باشد",
password: "کلمه عبور",
pwRules: "کلمه عبور نمی‌تواند خالی باشد",
},
menu: {
logout: "خروج",
},
admin: {
changeCred: "ویرایش داده‌ها",
oldPass: "رمز کنونی",
newUname: "نام کاربری جدید",
newPass: "رمز جدید",
lastLogin: "آخرین ورود",
date: "تاریخ",
time: "ساعت",
changes: "تغییرات",
actor: "مجری",
key: "کلید",
action: "عمل",
api: {
title: "توکن‌های API",
msg: "لطفا توکن زیر را کپی کنید و در یک مکان امن نگهدارید. این توکن دیگر نمایش داده نخواهد شد.",
token: "توکن",
},
},
setting: {
interface: "نما",
sub: "سابسکریپشن",
addr: "آدرس",
port: "پورت",
webPath: "مسیر پایه",
domain: "دامنه",
sslKey: "مسیر فایل کلید",
sslCert: "مسیر فایل گواهی",
webUri: "آدرس نهایی پنل",
sessionAge: "بیشینه زمان لاگین ماندن",
trafficAge: "بیشینه زمان ذخیره ترافیک",
timeLoc: "منطقه زمانی",
subEncode: "رمزگذاری",
subInfo: "نمایش اطلاعات کاربر",
path: "مسیر پیشفرض",
update: "زمان بروزرسانی خودکار",
subUri: "آدرس نهایی سابسکریپشن",
jsonSub: "سابسکریپشن JSON",
toDirect: "هدایت مستقیم",
toBlock: "بستن مسیر",
timestamp: "نمایش زمان",
globalDns: "DNS کلی",
directDns: "DNS مستقیم",
toDirectDns: "هدایت به DNS مستقیم",
jsonSubOptions: "گزینه‌های دیگر",
excludePkg: "نرم‌افزارهای استثنا",
clashSub: "سابسکریپشن CLASH",
mixedPort: "ورودی Mixed",
tun: "ورودی TUN",
},
client: {
name: "نام",
desc: "شرح",
group: "گروه",
inboundTags: "برچسب‌های ورودی",
basics: "پایه",
config: "تنظیم",
links: "لینک‌ها",
external: "لینک‌ خارجی",
sub: "سابسکریپشن خارجی",
delayStart: "تأخیر شروع",
autoReset: "بازنشانی خودکار",
resetDays: "روزهای بازنشانی",
nextReset: "بازنشانی بعدی",
},
bulk: {
order: "ترتیب",
random: "تصادفی",
changeLimits: "تغییر محدودیت‌ها",
addInbounds: "افزودن اینباندها",
removeInbounds: "حذف اینباندها",
addDays: "افزودن روز",
addVolume: "افزودن حجم",
},
types: {
un: "نام کاربری",
pw: "رمز",
direct: {
overrideAddr: "جایگزین آدرس",
overridePort: "جایگزین پورت",
},
hy: {
obfs: "رمز مبهم کننده",
auth: "رمز احراز هویت",
hyOptions: "گزینه‌های Hysteria",
hy2Options: "گزینه‌های Hysteria2",
ignoreBw: "نادیده‌گرفتن پهنای‌باند کاربر",
},
shdwTls: {
hs: "سرور دست‌تکانی",
addHS: "افزودن سرور دست‌تکانی",
},
ssh: {
passphrase: "عبارت عبور",
hostKey: "کلیدهای هاست‌ها",
algorithm: "الگوریتم‌ها",
clientVer: "نسخه کلاینت",
options: "گزینه‌های SSH",
},
tor: {
execPath: "مسیر فایل اجرایی",
dataDir: "پوشه داده‌ها",
extArgs: "آرگومان‌های اضافی",
},
tuic: {
congControl: "کنترل ازدحام",
authTimeout: "مهلت احراز هویت",
hb: "ضربان قلب",
},
tun: {
addr: "آدرس‌ها",
ifName: "نام اینترفیس",
excludeMptcp: "حذف MPTCP",
fallbackRuleIndex: "شاخص قوانین fallback در iproute2",
},
vless: {
flow: "جریان",
udpEnc: "کدگذاری بسته UDP",
},
vmess: {
security: "امنیت",
globalPadding: "لایه بندی کلی",
authLen: "رمزگذاری اندازه بسته",
},
wg: {
privKey: "کلید خصوصی",
pubKey: "کلید عمومی همتا",
psk: "کلید مشترک",
localIp: "آدرس‌های محلی",
worker: "عملگرها",
ifName: "نام اینترفیس",
sysIf: "استفاده از اینترفیس سیستم",
options: "گزینه‌های Wireguard",
allowedIp: "آدرس‌های مجاز",
peer: "همتا",
peers: "همتاها",
},
lb: {
defaultOut: "خروجی پیش‌فرض",
interruptConn: "قطع ارتباط موجود",
testUrl: "URL تست",
interval: "فاصله زمانی",
tolerance: "تحمل",
urlTestOptions: "گزینه‌های URLTest"
},
ts: {
options: "گزینه‌های Tailscale",
stateDir: "مسیر پوشه وضعیت",
authKey: "کلید احراز هویت",
relayServer: "سرور رله",
relayServerPort: "پورت سرور رله",
relayEndpoints: "نقاط انتهایی ثابت رله",
systemInterface: "رابط سیستمی",
sysIfName: "نام رابط",
sysIfMtu: "MTU رابط",
controlUrl: "درگاه کنترل",
ephemeral: "حالت موقتی",
hostname: "نام هاست",
acceptRoutes: "پذیرش مسیرها",
exitNode: "درگاه خروج",
allowLanAccess: "دسترسی به LAN",
advRoutes: "تبلیغ مسیرها",
advExitNode: "تبلیغ درگاه خروج",
udpTimeout: "مهلت UDP",
},
ocm: {
credentialPath: "مسیر اعتبارنامه",
usagesPath: "مسیر آمار استفاده",
users: "کاربران",
userName: "نام",
userToken: "توکن",
},
ccm: {
credentialPath: "مسیر اعتبارنامه",
usagesPath: "مسیر آمار استفاده",
users: "کاربران",
userName: "نام",
userToken: "توکن",
},
derp: {
configPath: "مسیر پیکربندی",
verifyClientEndpoint: "درگاه تایید کلاینت",
verifyClientUrl: "URL تایید کلاینت",
meshWith: "شبکه مش",
meshPsk: "کلید پیش‌اشتراک‌گذاری",
meshPskFile: "فایل کلید پیش‌اشتراک‌گذاری",
stun: "سرور STUN",
options: "گزینه‌های DERP",
},
naive: {
insecureConcurrency: "همزمانی ناامن",
quic: "QUIC",
quicCongestion: "کنترل تراکم QUIC",
udpOverTcp: "UDP روی TCP",
},
anytls: {
idleInterval: "فاصله بررسی جلسه غیرفعال",
idleTimeout: "زمان پایان جلسه غیرفعال",
minIdle: "حداقل جلسات غیرفعال",
},
},
in: {
addr: "آدرس",
port: "پورت",
ssMethod: "روش",
ssManageable: "قابل مدیریت",
sSide: "سمت سرور",
cSide: "سمت کاربر",
multiDomain: "دامنه چندگانه",
remark: "شرح",
mdOption: "گزینه‌های دامنه چندگانه",
},
listen: {
options: "گزینه‌های گوش‌دادن",
tcpOptions: "گزینه‌های TCP",
udpOptions: "گزینه‌های UDP",
detour: "انحراف مسیر",
detourText: "ارسال به ورودی دیگر",
disableTcpKeepAlive: "غیرفعال‌سازی TCP Keep Alive",
tcpKeepAlive: "TCP Keep Alive",
tcpKeepAliveInterval: "فاصله TCP Keep Alive",
},
dial: {
bindIf: "اتصال به کارت شبکه",
bindIp4: "اتصال به IPv4",
bindIp6: "اتصال به IPv6",
bindNoPort: "اتصال آدرس بدون پورت",
reuseAddr: "استفاده مجدد از آدرس",
connTimeout: "مهلت ارتباط",
disableTcpKeepAlive: "غیرفعال‌سازی TCP Keep Alive",
tcpKeepAlive: "TCP Keep Alive",
tcpKeepAliveInterval: "فاصله TCP Keep Alive",
domainResolver: "حل‌کننده دامنه",
options: "گزینه‌های تماس",
detourText: "ارسال به خروجی دیگر",
},
transport: {
enable: "فعال‌سازی انتقال",
host: "دامنه",
hosts: "دامنه‌ها",
path: "مسیر",
httpMethod: "متد درخواست",
idleTimeout: "مهلت بیکاری",
pingTimeout: "مهلت پینگ",
grpcServiceName: "نام سرویس",
grpcPws: "حفظ ارتباط بدون دیتا",
},
mux: {
enable: "فعال‌سازی تسهیم",
maxConn: "بیشینه ارتباطات",
minStr: "کمینه استریم",
maxStr: "بیشینه استریم",
padding: "فقط با پدینگ",
enableBrutal: "فعال‌سازی شدت",
},
out: {
addr: "آدرس سرور",
port: "پورت سرور",
addUrlTest: "افزودن URLTest",
delay: "تأخیر"
},
rule: {
add: "ایجاد قانون",
simple: "ساده",
logical: "منطقی",
mode: "حالت",
invert: "برعکس",
ipVer: "نسخه IP",
domain: "دامنه‌ها",
domainSufix: "پسوند‌های دامنه",
domainKw: "کلمات کلیدی دامنه",
domainRgx: "رجکس دامنه",
ip: "محدوده‌های IP",
privateIp: "آدرس های IP نامعتبر",
port: "پورت‌ها",
portRange: "محدوده‌های پورت",
srcCidr: "محدوده‌های آدرس IP مبدا",
srcPrivateIp: "آدرس‌های IP مبدا نامعتبر",
srcPort: "پورت‌های مبدا",
srcPortRange: "محدوده پورتهای منبع",
ruleset: "مجموعه‌ها",
rulesetMatchSrc: "تطابق آدرس‌های مبدا با مجموعه قوانین",
preferredBy: "ترجیح داده شده توسط",
interfaceAddr: "آدرس رابط شبکه",
options: "گزینه‌های قوانین",
domainRules: "دامنه/آدرس",
srcIpRules: "آدرس مبدا",
srcPortRules: "پورت مبدا",
udpDisableDomainUnmapping: "عدم تبدیل مسیریابی دامنه",
udpConnect: "اتصال UDP",
udpTimeout: "مهلت UDP",
method: "روش",
noDrop: "عدم رهاکردن",
sniffer: "شنود کننده",
timeout: "مهلت",
strategy: "استراتژی",
etaHint: "یک آیتم برای هر خط وارد کنید. خطوط خالی و تکراری حذف خواهند شد.",
import: {
title: "ورود دسته‌ای مجموعه‌های قانون",
rulesTitle: "ورود قوانین",
urlsHint: "در هر خط یک URL. برچسب از نام فایل بدون پسوند تعیین می‌شود.",
fileHint: "فایل .txt حاوی URLها را بارگذاری کنید؛ در هر خط یک مورد.",
jsonHint: "یک شی JSON با آرایه‌های rules و/یا rule_set قرار دهید. می‌توانید کل بلوک «route»: {'{'}...{'}'} یا فقط محتوایش را بچسبانید.",
fileJsonHint: "فایل .json حاوی بلوک route را بارگذاری کنید.",
urlHint: "لینک مستقیم فایل JSON را وارد کنید (مثلاً لینک raw گیت‌هاب).",
preview: "پیش‌نمایش",
skipped: "از قبل وجود دارند، به‌صورت خاکستری نمایش داده شده‌اند",
conflict: "تعارض شناسایی شد",
merge: "ادغام — افزودن قوانین واردشده (رد برچسب تکراری مجموعه‌ها)",
replace: "جایگزینی — حذف قوانین و مجموعه‌های موجود و ورود دوباره",
pasteUrls: "چسباندن URL",
uploadTxt: "بارگذاری .txt",
uploadFile: "بارگذاری فایل",
fromUrl: "از طریق لینک",
selectTxt: "انتخاب فایل .txt",
selectJson: "انتخاب فایل .json",
parse: "تجزیه",
conflictDesc: "پیکربندی از قبل {rules} قانون و {rulesets} مجموعه قانون دارد. عمل را انتخاب کنید:",
finalOutbound: "خروجی پیش‌فرض (final)",
applyFinal: "اعمال به‌عنوان خروجی پیش‌فرض",
errNoArrays: 'هیچ آرایهٔ «rules» یا «rule_set» یافت نشد.',
errJsonParse: "خطای تجزیهٔ JSON: <p dir='ltr'>{message}</p>",
errNoArraysFetched: 'در JSON دریافت‌شده «rules» یا «rule_set» یافت نشد.',
errFetch: "خطای دریافت: <p dir='ltr'>{message}</p>",
errNoFile: "فایلی انتخاب نشده است.",
errNoArraysInFile: 'در فایل «rules» یا «rule_set» یافت نشد.',
},
},
ruleset: {
add: "ایجاد مجموعه",
format: "فرمت داده‌ها",
interval: "بازه بروزرسانی‌ها",
remote: "راه دور",
local: "محلی",
},
dns: {
add: "ایجاد سرور DNS",
title: "سرورهای DNS",
final: "سرور نهایی",
server: "سرور",
firstServer: "سرور نخست",
cacheCapacity: "ظرفیت cache",
disableCache: "غیرفعال‌سازی cache",
disableExpire: "بدون انقضا",
independentCache: "استقلال cache",
reverseMapping: "نگاشت معکوس",
domainStrategy: "استراتژی دامنه",
local: { preferGo: "ترجیح Go" },
rule: {
add: "ایجاد قانون DNS",
title: "قوانین DNS",
inet4Range: "محدوده IPv4",
inet6Range: "محدوده IPv6",
acceptDefault: "پذیرش پیش‌فرض",
action: {
title: "عملیات",
route: "مسیریابی",
routeOptions: "گزینه‌های مسیریابی",
reject: "رد کردن",
predefined: "پیش تعریف شده",
rewriteTtl: "بازنویسی TTL",
clientSubnet: "زیر شبکه کاربر",
rcode: "کد جواب",
rcodes: {
noError: "درست",
formerr: "درخواست نامعتبر",
servFail: "خطای سرور",
nxDomain: "یافت نشد",
refused: "رد شده",
notImp: "پیاده‌سازی نشده",
},
answer: "پاسخ‌ها",
ns: "سرورهای دامنه",
extra: "اضافی",
},
},
},
basic: {
log: {
title: "گزارش‌ها",
level: "سطح",
output: "خروجی",
timestamp: "فعال‌سازی ثبت زمان",
},
routing: {
title: "مسیریابی",
defaultOut: "خروجی پیش‌فرض",
defaultIf: "کارت شبکه پیش‌فرض",
defaultRm: "Routing Mark پیش‌فرض",
defaultDns: "DNS پیش‌فرض",
autoBind: "انتخاب اتوماتیک کارت شبکه",
},
exp: {
storeFakeIp: "ذخیره آدرس‌های نامعتبر",
extController: "کنترل‌گر خارجی",
extUi: "رابط‌کاربری خارجی",
extUiDownloadUrl: "آدرس دانلود رابط‌کاربری",
extUiDownloadDetour: "خروجی دانلود رابط‌کاربری",
secret: "رمز",
defaultMode: "حالت پیش‌فرض",
allowOrigin: "اجازه از مبدا",
allowPrivate: "اجازه شبکه خصوصی",
},
},
tls: {
enable: "فعالسازی رمزنگاری",
usePath: "مسیر فایل",
useText: "متن گواهی",
certPath: "مسیر فایل گواهی",
keyPath: "مسیر فایل کلید",
cert: "گواهی",
key: "کلید",
options: "گزینه‌های رمز‌نگاری",
minVer: "کمینه نسخه",
maxVer: "بیشینه نسخه",
cs: "مدل‌های رمزنگاری",
privKey: "کلید خصوصی",
pubKey: "کلید عمومی",
disableSni: "غیرفعال‌سازی SNI",
insecure: "تایید ارتباط ناامن",
fragment: "تکه‌بندی",
fragmentDelay: "تاخیر تکه‌بندی جایگزین",
recordFragment: "تکه‌بندی چندگانه",
store: "انبار ریشه",
ktls: "TLS هسته",
kernelTx: "ارسال",
kernelRx: "دریافت",
queryServerName: "نام سرور ECH برای جستجو",
acme: {
options: "گزینه‌های ACME",
dataDir: "مسیر داده‌ها",
defaultDomain: "دامنه پیش‌فرض",
disableChallenges: "بستن چالش‌ها",
httpChallenge: "بستن چالش HTTP",
tlsChallenge: "بستن چالش TLS",
altPorts: "پورت‌های جایگزین",
altHport: "پورت جایگزین HTTP",
altTport: "پورت جایگزین TLS",
caProvider: "فراهم کننده گواهی",
customCa: "فراهم کننده دیگر",
extAcc: "حساب خارجی",
dns01: "چالش DNS01",
dns01Provider: "فراهم کننده چالش DNS01",
dns01Params: {
api_token: "توکن API",
zone_token: "توکن زون",
access_key_id: "شناسه کلید دسترسی",
access_key_secret: "رمز کلید دسترسی",
region_id: "شناسه منطقه",
security_token: "توکن امنیتی",
username: "نام کاربری",
password: "رمز عبور",
subdomain: "زیردامنه",
server_url: "آدرس سرور",
},
},
},
stats: {
upload: "آپلود",
download: "دانلود",
volume: "حجم",
usage: "استفاده",
enable: "فعال سازی گزارش ترافیک",
graphTitle: "نمودار ترافیک",
B: "ب",
KB: "ک‌ب",
MB: "م‌ب",
GB: "گ‌ب",
TB: "ت‌ب",
PB: "پ‌ب",
p: "پ",
Kp: "ک‌پ",
Mp: "م‌پ",
Gp: "گ‌پ",
Mbps: "م‌ب/ث",
},
date: {
expiry: "انقضا",
expired: "منقضی",
d: "ر",
h: "س",
m: "د",
s: "ث",
ms: "م‌ث",
}
}
+42
View File
@@ -0,0 +1,42 @@
import { createI18n } from 'vue-i18n'
import en from './en'
import fa from './fa'
import vi from './vi'
import zhcn from './zhcn'
import zhtw from './zhtw'
import ru from './ru'
export const i18n = createI18n({
legacy: false,
locale: localStorage.getItem("locale") ?? 'en',
fallbackLocale: 'en',
messages: {
en: en,
fa: fa,
vi: vi,
zhHans: zhcn,
zhHant: zhtw,
ru: ru
},
})
export const locale = (() => {
const l = i18n.global.locale.value
switch (l) {
case "zhHans":
return "zh-cn"
case "zhHant":
return "zh-tw"
default:
return l
}
})()
export const languages = [
{ title: 'English', value: 'en' },
{ title: 'فارسی', value: 'fa' },
{ title: 'Tiếng Việt', value: 'vi' },
{ title: '简体中文', value: 'zhHans' },
{ title: '繁體中文', value: 'zhHant' },
{ title: 'Русский', value: 'ru' },
]
+640
View File
@@ -0,0 +1,640 @@
export default {
success: "успех",
failed: "ошибка",
enable: "Включить",
disable: "Отключить",
none: "Никакие",
all: "Все",
loading: "Загрузка...",
confirm: "Вы уверены?",
yes: "да",
no: "нет",
unlimited: "бесконечный",
type: "Тип",
protocol: "Протокол",
submit: "Отправить",
reset: "Сбросить",
now: "Сейчас",
network: "Сеть",
copyToClipboard: "Копировать в буфер обмена",
noData: "Нет данных!",
invalidLogin: "Неверный логин!",
online: "В сети",
version: "Версия",
email: "Электронная почта",
commaSeparated: "(разделено запятыми)",
count: "Количество",
template: "Шаблон",
editor: "Редактор",
error: {
dplData: "Дублирующие данные",
core: "Ошибка Sing-Box",
invalidData: "Неверные данные",
},
theme: {
light: "Светлый",
dark: "Темный",
system: "Система",
},
pages: {
login: "Вход",
home: "Главная",
inbounds: "Входящие",
outbounds: "Исходящие",
services: "Устройства",
endpoints: "Эндпоинты",
clients: "Клиенты",
rules: "Правила",
tls: "Настройки TLS",
basics: "Основы",
dns: "DNS",
admins: "Администраторы",
settings: "Настройки",
},
main: {
tiles: "Плитки",
gauges: "Датчики",
charts: "Графики",
infos: "Информация",
gauge: {
cpu: "Загрузка ЦП",
mem: "Загрузка ОЗУ",
dsk: "Загрузка диска",
swp: "Загрузка Swap",
},
chart: {
cpu: "Мониторинг ЦП",
mem: "Мониторинг ОЗУ",
net: "Сетевой трафик",
pnet: "Сетевые пакеты",
dio: "Мониторинг диска",
},
info: {
sys: "Информация о системе",
sbd: "Информация о Sing-Box",
host: "Хост",
cpu: "ЦП",
core: "Ядро",
uptime: "Время работы",
startupTime: "Время запуска",
threads: "Потоки",
memory: "Память",
running: "Работает"
},
backup: {
title: "Резервное копирование и восстановление",
backup: "Скачать резервную копию",
restore: "Восстановить резервную копию",
exclStats: "Исключить графики",
exclChanges: "Исключить изменения",
sbConfig: "Скачать конфигурацию Sing-Box",
},
stats: {
title: "Использование и количество",
totalUsage: "Общее использование",
},
},
objects: {
inbound: "Входящий",
client: "Клиент",
outbound: "Исходящий",
endpoint: "Точка входа",
config: "Настройки",
rule: "Правило",
ruleset: "Набор правил",
service: "Устройство",
dnsserver: "DNS сервер",
dnsrule: "Правило DNS",
user: "Пользователь",
tag: "Тег",
listen: "Прослушивание",
dial: "Звонок",
tls: "TLS",
multiplex: "Мультиплекс",
transport: "Транспорт",
headers: "Заголовки",
key: "Ключ",
value: "Значение",
},
actions: {
action: "Действие",
add: "Добавить",
addbulk: "Добавить пакетно",
editbulk: "Редактировать пакетно",
delbulk: "Удалить пакетно",
new: "Новый",
edit: "Редактировать",
del: "Удалить",
clone: "Клонировать",
test: "Тест",
testAll: "Тестировать все",
save: "Сохранить",
update: "Обновить",
submit: "Отправить",
set: "Установить",
generate: "Генерировать",
disable: "Отключить",
close: "Закрыть",
restartApp: "Перезапустить приложение",
restartSb: "Перезапустить Singbox",
apply: "Применить",
},
login: {
title: "Вход",
username: "Имя пользователя",
unRules: "Имя пользователя не может быть пустым",
password: "Пароль",
pwRules: "Пароль не может быть пустым",
},
menu: {
logout: "Выйти",
},
admin: {
changeCred: "Изменить учетные данные",
oldPass: "Текущий пароль",
newUname: "Новое имя пользователя",
newPass: "Новый пароль",
lastLogin: "Последний вход",
date: "Дата",
time: "Время",
changes: "Изменения",
actor: "Исполнитель",
key: "Ключ",
action: "Действие",
api: {
title: "Токены API",
msg: "Пожалуйста, скопируйте токен ниже и сохраните его в безопасном месте. Он не будет показан заново.",
token: "Токен",
},
},
setting: {
interface: "Интерфейс",
sub: "Подписка",
addr: "Адрес",
port: "Порт",
webPath: "Базовый URI",
domain: "Домен",
sslKey: "Путь к SSL ключу",
sslCert: "Путь к SSL сертификату",
webUri: "URI панели",
sessionAge: "Максимальная длительность сессии",
trafficAge: "Максимальная длительность трафика",
timeLoc: "Часовой пояс",
subEncode: "Включить кодирование",
subInfo: "Включить информацию о клиенте",
path: "Путь по умолчанию",
update: "Время автоматического обновления",
subUri: "URI подписки",
jsonSub: "JSON подписка",
toDirect: "Маршрутизация на Direct",
toBlock: "Маршрутизация на Block",
timestamp: "Метка времени",
globalDns: "Глобальный DNS",
directDns: "Прямой DNS",
toDirectDns: "Маршрутизация на Direct DNS",
jsonSubOptions: "Другие параметры",
excludePkg: "Исключить пакеты",
clashSub: "Clash подписка",
mixedPort: "Смешанный порт",
tun: "Tun инбоунд",
},
client: {
name: "Имя",
desc: "Описание",
group: "Группа",
inboundTags: "Теги входящих",
basics: "Основы",
config: "Конфигурация",
links: "Ссылки",
external: "Внешняя ссылка",
sub: "Внешняя подписка",
delayStart: "Отложенный старт",
autoReset: "Авто сброс",
resetDays: "Дней до сброса",
nextReset: "Следующий сброс",
},
bulk: {
order: "Порядок",
random: "Случайный",
changeLimits: "Изменить лимиты",
addInbounds: "Добавить входящие",
removeInbounds: "Удалить входящие",
addDays: "Добавить дни",
addVolume: "Добавить объём",
},
types: {
un: "Имя пользователя",
pw: "Пароль",
direct: {
overrideAddr: "Переопределить адрес",
overridePort: "Переопределить порт",
},
hy: {
obfs: "Обфусцированный пароль",
auth: "Пароль аутентификации",
hyOptions: "Параметры Hysteria",
hy2Options: "Параметры Hysteria2",
ignoreBw: "Игнорировать пропускную способность клиента",
},
shdwTls: {
hs: "Сервер рукопожатий",
addHS: "Добавить сервер рукопожатий",
},
ssh: {
passphrase: "Парольная фраза",
hostKey: "Ключи хоста",
algorithm: "Алгоритмы ключей",
clientVer: "Версия клиента",
options: "Параметры SSH",
},
tor: {
execPath: "Путь к исполняемому файлу",
dataDir: "Каталог данных",
extArgs: "Дополнительные аргументы",
},
tuic: {
congControl: "Контроль перегрузок",
authTimeout: "Таймаут аутентификации",
hb: "Сердцебиение",
},
tun: {
addr: "Адреса",
ifName: "Имя интерфейса",
excludeMptcp: "Исключить MPTCP",
fallbackRuleIndex: "Индекс правила iproute2 fallback",
},
vless: {
flow: "Поток",
udpEnc: "Кодирование UDP пакетов",
},
vmess: {
security: "Безопасность",
globalPadding: "Глобальная подкладка",
authLen: "Длина шифрования",
},
wg: {
privKey: "Приватный ключ",
pubKey: "Публичный ключ пира",
psk: "Предварительно разделенный ключ",
localIp: "Локальные IP",
worker: "Работники",
ifName: "Имя интерфейса",
sysIf: "Системный интерфейс",
options: "Параметры Wireguard",
allowedIp: "Разрешенные IP",
peer: "Пир",
peers: "Пиры",
},
lb: {
defaultOut: "Исходящий по умолчанию",
interruptConn: "Прервать существующие соединения",
testUrl: "Тестовый URL",
interval: "Интервал",
tolerance: "Толерантность",
urlTestOptions: "Параметры URLTest"
},
ts: {
options: "Параметры Tailscale",
stateDir: "Каталог состояния",
authKey: "Ключ аутентификации",
relayServer: "Сервер ретрансляции",
relayServerPort: "Порт сервера ретрансляции",
relayEndpoints: "Статические точки ретрансляции",
systemInterface: "Системный интерфейс",
sysIfName: "Имя интерфейса",
sysIfMtu: "MTU интерфейса",
controlUrl: "URL управления",
ephemeral: "Эфемерный",
hostname: "Имя хоста",
acceptRoutes: "Принять маршруты",
exitNode: "Выходной узел",
allowLanAccess: "Разрешить доступ LAN",
advRoutes: "Рекламируемые маршруты",
advExitNode: "Рекламируемый выходной узел",
udpTimeout: "Таймаут UDP",
},
ocm: {
credentialPath: "Путь к учетным данным",
usagesPath: "Путь к статистике",
users: "Пользователи",
userName: "Имя",
userToken: "Токен",
},
ccm: {
credentialPath: "Путь к учетным данным",
usagesPath: "Путь к статистике",
users: "Пользователи",
userName: "Имя",
userToken: "Токен",
},
derp: {
configPath: "Путь к конфигурации",
verifyClientEndpoint: "Проверить конечную точку клиента",
verifyClientUrl: "Проверить URL клиента",
meshWith: "Сеть с",
meshPsk: "Предварительно разделенный ключ",
meshPskFile: "Файл предварительно разделенного ключа",
stun: "Сервер STUN",
options: "Параметры DERP",
},
naive: {
insecureConcurrency: "Небезопасная параллельность",
quic: "QUIC",
quicCongestion: "Управление перегрузкой QUIC",
udpOverTcp: "UDP через TCP",
},
anytls: {
idleInterval: "Интервал проверки неактивных сессий",
idleTimeout: "Тайм-аут неактивной сессии",
minIdle: "Минимум неактивных сессий"
},
},
in: {
addr: "Адрес",
port: "Порт",
ssMethod: "Метод",
ssManageable: "Управляемый",
sSide: "Сторона сервера",
cSide: "Сторона клиента",
multiDomain: "Мультидомен",
remark: "Примечание",
mdOption: "Параметры мультидомена",
},
listen: {
options: "Параметры прослушивания",
tcpOptions: "Параметры TCP",
udpOptions: "Параметры UDP",
detour: "Обход",
detourText: "Переадресация на входящий",
disableTcpKeepAlive: "Отключить TCP Keep Alive",
tcpKeepAlive: "TCP Keep Alive",
tcpKeepAliveInterval: "Интервал TCP Keep Alive",
},
dial: {
bindIf: "Привязка к сетевому интерфейсу",
bindIp4: "Привязка к IPv4",
bindIp6: "Привязка к IPv6",
bindNoPort: "Привязка адреса без порта",
reuseAddr: "Повторное использование адреса слушателя",
connTimeout: "Таймаут подключения",
disableTcpKeepAlive: "Отключить TCP Keep Alive",
tcpKeepAlive: "TCP Keep Alive",
tcpKeepAliveInterval: "Интервал TCP Keep Alive",
domainResolver: "Разрешение домена",
options: "Параметры вызова",
detourText: "Переадресация на исходящий",
},
transport: {
enable: "Включить транспорт",
host: "Хост",
hosts: "Хосты",
path: "Путь",
httpMethod: "Метод запроса",
idleTimeout: "Таймаут бездействия",
pingTimeout: "Таймаут пинга",
grpcServiceName: "Имя службы",
grpcPws: "Разрешить без потока",
},
mux: {
enable: "Включить мультиплекс",
maxConn: "Максимальное количество соединений",
minStr: "Минимальное количество потоков",
maxStr: "Максимальное количество потоков",
padding: "Только подкладка",
enableBrutal: "Включить Brutal",
},
out: {
addr: "Адрес сервера",
port: "Порт сервера",
addUrlTest: "Добавить URLTest",
delay: "Задержка",
},
rule: {
add: "Добавить правило",
simple: "Простое",
logical: "Логическое",
mode: "Режим",
invert: "Инвертировать",
ipVer: "Версия IP",
domain: "Домены",
domainSufix: "Суффиксы доменов",
domainKw: "Ключевые слова домена",
domainRgx: "Регулярные выражения домена",
ip: "CIDR IP",
privateIp: "Недействительные диапазоны IP",
port: "Порты",
portRange: "Диапазоны портов",
srcCidr: "CIDR исходного IP",
srcPrivateIp: "Недействительные исходные IP",
srcPort: "Исходные порты",
srcPortRange: "Диапазоны исходных портов",
ruleset: "Наборы правил",
rulesetMatchSrc: "Набор правил для соответствия источника IPcidr",
preferredBy: "Предпочтительный исходящий",
interfaceAddr: "Адрес интерфейса",
options: "Параметры правила",
domainRules: "Домен/IP",
srcIpRules: "Источник IP",
srcPortRules: "Источник порта",
udpDisableDomainUnmapping: "Отключить перенос доменных имен",
udpConnect: "Подключение UDP",
udpTimeout: "Таймаут UDP",
method: "Метод",
noDrop: "Не сбрасывать",
sniffer: "Обнаружение",
timeout: "Таймаут",
strategy: "Стратегия",
etaHint: "По одной записи в строке. Пустые строки и дубликаты игнорируются.",
import: {
title: "Массовый импорт наборов правил",
rulesTitle: "Импорт правил",
urlsHint: "По одному URL в строке. Тег определяется из имени файла без расширения.",
fileHint: "Загрузите .txt файл с URL-адресами, по одному в строке.",
jsonHint: "Вставьте JSON-объект с массивами rules и/или rule_set. Можно вставить весь блок \"route\": {'{'}...{'}'} или только его содержимое.",
fileJsonHint: "Загрузите .json файл с блоком route.",
urlHint: "Укажите прямую ссылку на JSON-файл (например, raw-ссылку GitHub).",
preview: "Предпросмотр",
skipped: "уже существуют, показаны серым",
conflict: "Обнаружены конфликты",
merge: "Объединить — добавить импортируемые правила (пропустить дубликаты тегов наборов)",
replace: "Заменить — удалить существующие правила и наборы, импортировать заново",
pasteUrls: "Вставить URL",
uploadTxt: "Загрузить .txt",
uploadFile: "Загрузить файл",
fromUrl: "По ссылке",
selectTxt: "Выберите .txt файл",
selectJson: "Выберите .json файл",
parse: "Разобрать",
conflictDesc: "В конфиге уже есть {rules} правил и {rulesets} наборов правил. Выберите действие:",
finalOutbound: "Outbound по умолчанию (final)",
applyFinal: "Применить как outbound по умолчанию",
errNoArrays: 'Массивы "rules" или "rule_set" не найдены.',
errJsonParse: "Ошибка разбора JSON: {message}",
errNoArraysFetched: 'В полученном JSON нет "rules" или "rule_set".',
errFetch: "Ошибка загрузки: {message}",
errNoFile: "Файл не выбран.",
errNoArraysInFile: 'В файле нет "rules" или "rule_set".',
},
},
ruleset: {
add: "Добавить набор правил",
format: "Формат данных",
interval: "Интервалы обновления",
remote: "Удаленный",
local: "Локальный",
},
dns: {
add: "Добавить DNS сервер",
title: "DNS серверы",
final: "Итоговый",
server: "Сервер",
firstServer: "Первый сервер",
cacheCapacity: "Вместимость кэша",
disableCache: "Отключить кэш",
disableExpire: "Отключить истечение",
independentCache: "Независимый кэш",
reverseMapping: "Обратное отображение",
domainStrategy: "Стратегия домена",
local: { preferGo: "Предпочитать Go" },
rule: {
add: "Добавить правило DNS",
title: "Правила DNS",
inet4Range: "Диапазон IPv4",
inet6Range: "Диапазон IPv6",
acceptDefault: "Принять резолверы по умолчанию",
action: {
title: "Действие",
route: "Маршрутизация",
routeOptions: "Параметры маршрутизации",
reject: "Отклонить",
predefined: "Предопределенные",
rewriteTtl: "Перезаписать TTL",
clientSubnet: "Подсеть клиента",
rcode: "Код ответа",
rcodes: {
noError: "ОК",
formerr: "Неверный запрос",
servFail: "Сбой сервера",
nxDomain: "Не найдено",
refused: "Отклонено",
notImp: "Не реализовано"
},
answer: "Ответ",
ns: "Серверы имён",
extra: "Дополнительные",
},
}
},
basic: {
log: {
title: "Журналы",
level: "Уровень",
output: "Вывод",
timestamp: "Включить метку времени",
},
routing: {
title: "Маршрутизация",
defaultOut: "Исходящий по умолчанию",
defaultIf: "Сетевой интерфейс по умолчанию",
defaultRm: "Маршрут по умолчанию",
defaultDns: "DNS по умолчанию",
autoBind: "Автопривязка сетевого интерфейса",
},
exp: {
storeFakeIp: "Хранить поддельный IP",
extController: "Внешний контроллер",
extUi: "Внешний интерфейс",
extUiDownloadUrl: "URL загрузки интерфейса",
extUiDownloadDetour: "Обход загрузки интерфейса",
secret: "Секрет",
defaultMode: "Режим по умолчанию",
allowOrigin: "Разрешить источник",
allowPrivate: "Разрешить частную сеть"
},
},
tls: {
enable: "Включить TLS",
usePath: "Использовать путь",
useText: "Использовать текст",
certPath: "Путь к файлу сертификата",
keyPath: "Путь к файлу ключа",
cert: "Сертификат",
key: "Ключ",
options: "Параметры TLS",
minVer: "Минимальная версия",
maxVer: "Максимальная версия",
cs: "Наборы шифров",
privKey: "Приватный ключ",
pubKey: "Публичный ключ",
disableSni: "Отключить SNI",
insecure: "Разрешить небезопасное",
fragment: "Фрагментация",
fragmentDelay: "Задержка фрагментации",
recordFragment: "Фрагментация записей",
store: "Корневое хранилище",
ktls: "Ядро TLS",
kernelTx: "TX",
kernelRx: "RX",
queryServerName: "ECH имя сервера для запроса",
acme: {
options: "Параметры ACME",
dataDir: "Каталог данных",
defaultDomain: "Домен по умолчанию",
disableChallenges: "Отключить вызовы",
httpChallenge: "Отключить HTTP вызов",
tlsChallenge: "Отключить TLS вызов",
altPorts: "Альтернативные порты",
altHport: "Альтернативный HTTP порт",
altTport: "Альтернативный TLS порт",
caProvider: "Поставщик CA",
customCa: "Пользовательский поставщик CA",
extAcc: "Внешний аккаунт",
dns01: "DNS01 вызов",
dns01Provider: "Поставщик DNS01 вызова",
dns01Params: {
api_token: "API Токен",
zone_token: "Токен зоны",
access_key_id: "ID ключа доступа",
access_key_secret: "Секрет ключа доступа",
region_id: "ID региона",
security_token: "Токен безопасности",
username: "Имя пользователя",
password: "Пароль",
subdomain: "Поддомен",
server_url: "URL сервера",
},
},
},
stats: {
upload: "Загрузка",
download: "Скачивание",
volume: "Объем",
usage: "Использование",
enable: "Включить статистику",
graphTitle: "График трафика",
B: "Б",
KB: "КБ",
MB: "МБ",
GB: "ГБ",
TB: "ТБ",
PB: "ПБ",
p: "п",
Kp: "Кп",
Mp: "Мп",
Gp: "Гп",
Mbps: "Мб/с",
},
date: {
expiry: "Срок действия",
expired: "Истек",
d: "д",
h: "ч",
m: "м",
s: "с",
ms: "мс",
},
}
+635
View File
@@ -0,0 +1,635 @@
export default {
success: "Thành công",
failed: "Thất bại",
enable: "Kích hoạt",
disable: "Vô hiệu hóa",
none: "Không",
all: "Tất cả",
loading: "Đang tải...",
confirm: "Bạn chắc chắn chứ?",
yes: "có",
no: "không",
unlimited: "vô hạn",
type: "Loại",
protocol: "Giao thức",
submit: "Gửi",
reset: "Đặt lại",
now: "Hiện tại",
network: "Mạng",
copyToClipboard: "Sao chép vào clipboard",
noData: "Không có dữ liệu!",
invalidLogin: "Đăng nhập không hợp lệ!",
online: "Trực tuyến",
version: "Phiên bản",
email: "Email",
commaSeparated: "(được phân tách bằng dấu phẩy)",
count: "Đếm",
template: "Mẫu",
editor: "Bản sử dụng",
error: {
dplData: "Dữ liệu trùng lặp",
core: "Lỗi Sing-Box",
invalidData: "Dữ liệu khỏ hợp lệ",
},
theme: {
light: "Nhật",
dark: "Xanh",
system: "Phòng bán",
},
pages: {
login: "Đăng nhập",
home: "Trang chủ",
inbounds: "Đầu Vào",
outbounds: "Đầu ra",
services: "Thiết bị",
endpoints: "Câu hình",
clients: "Khách hàng",
rules: "Quy tắc",
tls: "Cài đặt TLS",
basics: "Cơ bản",
dns: "DNS",
admins: "Quản trị viên",
settings: "Cài đặt",
},
main: {
tiles: "OHB",
gauges: "Đồng hồ đo",
charts: "Biểu đồ",
infos: "Thông tin",
gauge: {
cpu: "Đồng hồ CPU",
mem: "Đồng hồ RAM",
dsk: "Đồng hồ Disk",
swp: "Đồng hồ Swap",
},
chart: {
cpu: "Máy theo dõi CPU",
mem: "Máy theo dõi RAM",
net: "Băng thông mạng",
pnet: "Gói mạng",
dio: "Disk I/O",
},
info: {
sys: "Thông tin hệ thống",
sbd: "Thông tin Sing-Box",
host: "Máy chủ",
cpu: "CPU",
core: "Nhân",
uptime: "Thời gian hoạt động",
startupTime: "Thời gian khởi động",
threads: "Luồng",
memory: "Bộ nhớ",
running: "Đang chạy"
},
backup: {
title: "Sao lưu và khôi phục",
backup: "Tải xuống bản sao lưu",
restore: "Khôi phục bản sao lưu",
exclStats: "Loại trừ các biểu đồ",
exclChanges: "Loại trừ các thay đổi",
sbConfig: "Tải xuống cấu hình Sing-Box",
},
stats: {
title: "Sử dụng và số lượng",
totalUsage: "Tổng sử dụng",
},
},
objects: {
inbound: "Đầu Vào",
client: "Máy Khách hàng",
outbound: "Đầu Ra",
endpoint: "Điểm cuối",
config: "Câu hình",
rule: "Quy tắc",
ruleset: "Bộ quy tắc",
service: "Dịch vụ",
dnsserver: "Máy chủ DNS",
dnsrule: "Quy tắc DNS",
user: "Người dùng",
tag: "Thẻ",
listen: "Nghe",
dial: "Quay số",
tls: "TLS",
multiplex: "Ghép đa truyền thông ",
transport: "Giao thông",
headers: "Tiêu đề",
key: "Chìa khóa",
value: "Giá trị",
},
actions: {
action: "Hành động",
add: "Thêm",
addbulk: "Thêm Hàng loạt",
editbulk: "Chỉnh sửa hàng loạt",
delbulk: "Xóa hàng loạt",
new: "Mới",
edit: "Chỉnh sửa",
del: "Xóa",
clone: "Nhân bản",
test: "Kiểm tra",
testAll: "Kiểm tra tất cả",
save: "Lưu",
update: "Cập nhật",
submit: "Gửi",
set: "Đặt",
generate: "Tạo ra",
disable: "Vô hiệu hóa",
close: "Đóng",
restartApp: "Khởi động lại ứng dụng",
restartSb: "Khởi động lại Singbox",
},
login: {
title: "Đăng nhập",
username: "Tên người dùng",
unRules: "Tên người dùng không thể trống",
password: "Mật khẩu",
pwRules: "Mật khẩu không thể trống",
},
menu: {
logout: "Đăng xuất",
},
admin: {
changeCred: "Thay đổi thông tin đăng nhập",
oldPass: "Mật khẩu hiện tại",
newUname: "Tên người dùng mới",
newPass: "Mật khẩu mới",
lastLogin: "Lân đăng nhập cuôi",
date: "Ngày",
time: "Thời gian",
changes: "Thay đổi",
actor: "Diễn viên",
key: "Khóa",
action: "Hành động",
api: {
title: "Mã thông báo API",
msg: "Vui lòng sao chép mã thông báo bên dưới và lưu trữ nó ở nơi an toàn. Nó sẽ không được hiển thị lại.",
token: "Mã thông báo"
},
},
setting: {
interface: "Giao diện",
sub: "Đăng ký",
addr: "Địa chỉ",
port: "Cổng",
webPath: "Đường dẫn gốc",
domain: "Miền",
sslKey: "Đường dẫn khóa SSL",
sslCert: "Đường dẫn chứng chỉ SSL",
webUri: "URI bảng điều khiển",
sessionAge: "Tuổi tối đa của phiên",
trafficAge: "Tuổi lưu thông tối đa",
timeLoc: "Vị trí múi giờ",
subEncode: "Kích hoạt mã hóa",
subInfo: "Kích hoạt thông tin khách hàng",
path: "Đường dẫn mặc định",
update: "Thời gian cập nhật tự động",
subUri: "URI đăng ký",
jsonSub: "Đăng ký JSON",
toDirect: "Chuyển hướng tới Trực tiếp",
toBlock: "Chuyển hướng tới Chặn",
timestamp: "Dấu thời gian",
globalDns: "DNS Toàn cầu",
directDns: "DNS Trực tiếp",
toDirectDns: "Chuyển hướng tới DNS Trực tiếp",
jsonSubOptions: "Tùy chọn Khác",
excludePkg: "Loại trừ Gói",
clashSub: "Clash đăng ký",
mixedPort: "Cổng khóa",
tun: "Tun đăng ký",
},
client: {
name: "Tên",
desc: "Mô tả",
group: "Nhóm",
inboundTags: "Thẻ đầu vào",
basics: "Cơ bản",
config: "Cấu hình",
links: "Liên kết",
external: "Liên kết bên ngoài",
sub: "Đăng ký bên ngoài",
delayStart: "Trì hoãn bắt đầu",
autoReset: "Tự động đặt lại",
resetDays: "Số ngày đặt lại",
nextReset: "Đặt lại lần sau",
},
bulk: {
order: "Sắp xếp",
random: "Ngẫu nhiên",
changeLimits: "Thay đổi giới hạn",
addInbounds: "Thêm inbound",
removeInbounds: "Xóa inbound",
addDays: "Thêm ngày",
addVolume: "Thêm dung lượng",
},
types: {
un: "Tên người dùng",
pw: "Mật khẩu",
direct: {
overrideAddr: "Ghi đè Địa chỉ",
overridePort: "Ghi đè Cổng",
},
hy: {
obfs: "Mật khẩu đã được Ẩn",
auth: "Mật khẩu Xác thực",
hyOptions: "Tùy chọn Hysteria",
hy2Options: "Tùy chọn Hysteria2",
ignoreBw: "Bỏ qua Băng thông của Client",
},
shdwTls: {
hs: "Máy chủ Handshake",
addHS: "Thêm Máy chủ Handshake",
},
ssh: {
passphrase: "Cụm từ mật khẩu",
hostKey: "Khóa Máy chủ",
algorithm: "Thuật toán Khóa",
clientVer: "Phiên bản Client",
options: "Tùy chọn SSH",
},
tor: {
execPath: "Đường dẫn File thực thi",
dataDir: "Thư mục Dữ liệu",
extArgs: "Đối số Bổ sung",
},
tuic: {
congControl: "Kiểm soát Tắc nghẽn",
authTimeout: "Thời gian chờ Xác thực",
hb: "Nhịp tim",
},
tun: {
addr: "Địa chỉ",
ifName: "Tên Giao diện",
excludeMptcp: "Loại trừ MPTCP",
fallbackRuleIndex: "Chỉ số quy tắc dự phòng iproute2",
},
vless: {
flow: "Luồng",
udpEnc: "Mã hóa Gói UDP",
},
vmess: {
security: "Bảo mật",
globalPadding: "Đệm Toàn cầu",
authLen: "Chiều dài Mã hóa",
},
wg: {
privKey: "Khóa Riêng tư",
pubKey: "Khóa Công khai của Đối tác",
psk: "Khóa được Chia sẻ trước",
localIp: "IPs Cục bộ",
worker: "Công nhân",
ifName: "Tên Giao diện",
sysIf: "Giao diện Hệ thống",
options: "Tùy chọn Wireguard",
allowedIp: "IPs được Phép",
peer: "Đối tác",
peers: "Đối tác",
},
lb: {
defaultOut: "Đầu ra Mặc định",
interruptConn: "Ngắt kết nối hiện tại",
testUrl: "URL Kiểm tra",
interval: "Khoảng thời gian",
tolerance: "Sự dung hòa",
urlTestOptions: "Tùy chọn URLTest",
},
ts: {
options: "Tùy chọn Tailscale",
stateDir: "Thư mục Trạng thái",
authKey: "Khóa Xác thực",
relayServer: "Máy chủ chuyển tiếp",
relayServerPort: "Cổng máy chủ chuyển tiếp",
relayEndpoints: "Điểm cuối tĩnh chuyển tiếp",
systemInterface: "Giao diện hệ thống",
sysIfName: "Tên giao diện",
sysIfMtu: "MTU giao diện",
controlUrl: "URL Cấu hình",
ephemeral: "Tạm thời",
hostname: "Tên máy chủ",
acceptRoutes: "Chấp nhận Đường dẫn",
exitNode: "Nút thoát",
allowLanAccess: "Cho phép Truy cập LAN",
advRoutes: "Quảng bá Đường dẫn",
advExitNode: "Quảng bá Nút thoát",
udpTimeout: "Thời gian Chờ UDP",
},
ocm: {
credentialPath: "Đường dẫn Thông tin xác thực",
usagesPath: "Đường dẫn Thống kê",
users: "Người dùng",
userName: "Tên",
userToken: "Token",
},
ccm: {
credentialPath: "Đường dẫn Thông tin xác thực",
usagesPath: "Đường dẫn Thống kê",
users: "Người dùng",
userName: "Tên",
userToken: "Token",
},
derp: {
configPath: "Đường dẫn Cấu hình",
verifyClientEndpoint: "Điểm cuối Xác minh Máy khách",
verifyClientUrl: "URL Xác minh Máy khách",
meshWith: "Kết nối Mesh với",
meshPsk: "Khóa PSK Mesh",
meshPskFile: "Tệp Khóa PSK Mesh",
stun: "Máy chủ STUN",
options: "Tùy chọn DERP",
},
naive: {
insecureConcurrency: "Đồng thời không an toàn",
quic: "QUIC",
quicCongestion: "Điều khiển tắc nghẽn QUIC",
udpOverTcp: "UDP qua TCP",
},
anytls: {
idleInterval: "Khoảng kiểm tra phiên nhàn rỗi",
idleTimeout: "Thời gian chờ phiên nhàn rỗi",
minIdle: "Số phiên nhàn rỗi tối thiểu"
},
},
in: {
addr: "Địa chỉ",
port: "Cổng",
ssMethod: "Phương thức",
ssManageable: "Quản lý được",
sSide: "Phía Máy chủ",
cSide: "Phía Khách hàng",
multiDomain: "Nhiều Tên miền",
remark: "Ghi chú",
mdOption: "Tùy chọn Nhiều Tên miền",
},
listen: {
options: "Tùy chọn Nghe",
tcpOptions: "Tùy chọn TCP",
udpOptions: "Tùy chọn UDP",
detour: "Lạc đạo",
detourText: "Chuyển tiếp tới đầu vào",
disableTcpKeepAlive: "Tắt TCP Keep Alive",
tcpKeepAlive: "TCP Keep Alive",
tcpKeepAliveInterval: "Khoảng thời gian TCP Keep Alive",
},
dial: {
bindIf: "Ràng buộc tới Giao diện Mạng",
bindIp4: "Ràng buộc tới IPv4",
bindIp6: "Ràng buộc tới IPv6",
bindNoPort: "Ràng buộc địa chỉ không cổng",
reuseAddr: "Sử dụng lại Địa chỉ Nghe",
connTimeout: "Thời gian Chờ Kết nối",
disableTcpKeepAlive: "Tắt TCP Keep Alive",
tcpKeepAlive: "TCP Keep Alive",
tcpKeepAliveInterval: "Khoảng thời gian TCP Keep Alive",
domainResolver: "Trình phân giải Tên miền",
options: "Tùy chọn Gọi",
detourText: "Chuyển tiếp tới thư đi",
},
transport: {
enable: "Kích hoạt vận chuyển",
host: "Máy chủ",
hosts: "Máy chủ",
path: "Đường dẫn",
httpMethod: "Phương thức Yêu cầu",
idleTimeout: "Thời gian Chờ Chờ đợi",
pingTimeout: "Thời gian Chờ Ping",
grpcServiceName: "Tên Dịch vụ",
grpcPws: "Cho phép mà không Có Luồng",
},
mux: {
enable: "Bật Multiplex",
maxConn: "Số kết nối Tối đa",
minStr: "Số Luồng Tối thiểu",
maxStr: "Số Luồng Tối đa",
padding: "Chỉ đệm",
enableBrutal: "Bật Brutal",
},
out: {
addr: "Địa chỉ Máy chủ",
port: "Cổng Máy chủ",
addUrlTest: "Thêm URLTest",
delay: "Độ trễ",
},
rule: {
add: "Thêm Quy tắc",
simple: "Đơn giản",
logical: "Logic",
mode: "Chế độ",
invert: "Nghịch đảo",
ipVer: "Phiên bản IP",
domain: "Tên miền",
domainSufix: "Hậu tố Miền",
domainKw: "Từ khóa Miền",
domainRgx: "Regex Miền",
ip: "CIDRs IP",
privateIp: "Dải IP Không hợp lệ",
port: "Cổng",
portRange: "Dải Cổng",
srcCidr: "CIDRs IP Nguồn",
srcPrivateIp: "IP Nguồn Không hợp lệ",
srcPort: "Cổng Nguồn",
srcPortRange: "Dải Cổng Nguồn",
ruleset: "Bộ quy tắc",
rulesetMatchSrc: "Bộ quy tắc IPcidr Phù hợp Nguồn",
preferredBy: "Ưu tiên theo đầu ra",
interfaceAddr: "Địa chỉ giao diện",
options: "Tùy chọn Quy tắc",
domainRules: "Tên miền/IP",
srcIpRules: "IP Nguồn",
srcPortRules: "Cổng Nguồn",
udpDisableDomainUnmapping: "Không màm mạng tiền lập tên miền",
udpConnect: "Kết nối UDP",
udpTimeout: "Thời gian Chờ UDP",
method: "Phương pháp",
noDrop: "Không Tháp",
sniffer: "Kiểm tra Sniffer",
timeout: "Thời gian Chờ Sniffing",
strategy: "Chiến lệ",
etaHint: "Mỗi dòng một mục. Dòng trống và mục trùng lặp sẽ bị bỏ qua.",
import: {
title: "Nhập hàng loạt bộ quy tắc",
rulesTitle: "Nhập quy tắc",
urlsHint: "Một URL mỗi dòng. Thẻ được lấy từ tên tệp (không gồm phần mở rộng).",
fileHint: "Tải lên tệp .txt chứa URL, mỗi dòng một URL.",
jsonHint: "Dán đối tượng JSON có mảng rules và/hoặc rule_set. Có thể dán cả khối \"route\": {'{'}...{'}'} hoặc chỉ nội dung bên trong.",
fileJsonHint: "Tải lên tệp .json có khối route.",
urlHint: "Nhập liên kết trực tiếp tới tệp JSON (ví dụ liên kết raw GitHub).",
preview: "Xem trước",
skipped: "đã tồn tại, hiển thị màu xám",
conflict: "Phát hiện xung đột",
merge: "Gộp — thêm quy tắc đã nhập (bỏ qua thẻ bộ trùng)",
replace: "Thay thế — xóa quy tắc và bộ hiện có, nhập lại",
pasteUrls: "Dán URL",
uploadTxt: "Tải lên .txt",
uploadFile: "Tải lên tệp",
fromUrl: "Theo URL",
selectTxt: "Chọn tệp .txt",
selectJson: "Chọn tệp .json",
parse: "Phân tích",
conflictDesc: "Cấu hình đã có {rules} quy tắc và {rulesets} bộ quy tắc. Chọn thao tác:",
finalOutbound: "Outbound mặc định (final)",
applyFinal: "Áp dụng làm outbound mặc định",
errNoArrays: 'Không tìm thấy mảng "rules" hoặc "rule_set".',
errJsonParse: "Lỗi phân tích JSON: {message}",
errNoArraysFetched: 'Không tìm thấy "rules" hoặc "rule_set" trong JSON đã tải.',
errFetch: "Lỗi tải: {message}",
errNoFile: "Chưa chọn tệp.",
errNoArraysInFile: 'Không tìm thấy "rules" hoặc "rule_set" trong tệp.',
},
},
ruleset: {
add: "Thêm Bộ quy tắc",
format: "Định dạng Dữ liệu",
interval: "Khoảng cách Cập nhật",
remote: "Xa",
local: "Cục bộ",
},
dns: {
add: "Thêm Máy chủ DNS",
title: "Máy chủ DNS",
final: "Cuối cùng",
server: "Máy chủ",
firstServer: "Máy chủ Đầu tiên",
cacheCapacity: "Nội dung bộ nhớ",
disableCache: "Vô hiệu hóa bộ nhớ đệm",
disableExpire: "Vô hiệu hóa hệ thống",
independentCache: "Bộ nhớ rẽ",
reverseMapping: "Màm mạng tên lập",
domainStrategy: "Chiến lược Domain",
local: { preferGo: "Ưu tiên Go" },
rule: {
add: "Thêm Quy tắc DNS",
title: "Quy tắc DNS",
inet4Range: "Dải CIDR IPv4",
inet6Range: "Dải CIDR IPv6",
acceptDefault: "Chấp nhận Mặc định",
action: {
title: "Hành động",
route: "Định tuyến",
routeOptions: "Tùy chọn định tuyến",
reject: "Từ chối",
predefined: "Định nghĩa sẵn",
rewriteTtl: "Ghi đè TTL",
clientSubnet: "Subnet của máy khách",
rcode: "Máy chủ tên",
rcodes: {
noError: "OK",
formerr: "Yêu cầu không hợp lệ",
servFail: "Lỗi máy chủ",
nxDomain: "Không tìm thấy",
refused: "Bị từ chối",
notImp: "Chưa được hỗ trợ"
},
answer: "Câu trả lời",
ns: "Máy chủ tên",
extra: "Bổ sung"
}
}
},
basic: {
log: {
title: "Nhật ký",
level: "Mức độ",
output: "Đầu ra",
timestamp: "Bật Dấu thời gian",
},
routing: {
title: "Định tuyến",
defaultOut: "Ra ngoài Mặc định",
defaultIf: "NIC Mặc định",
defaultRm: "Đánh dấu Định tuyến Mặc định",
defaultDns: "Máy chủ DNS Mặc định",
autoBind: "Tự động Ràng buộc NIC",
},
exp: {
storeFakeIp: "Lưu IP Giả mạo",
extController: "Trình điều khiển bên ngoài",
extUi: "Giao diện người dùng bên ngoài",
extUiDownloadUrl: "URL tải giao diện",
extUiDownloadDetour: "Chuyển hướng tải giao diện",
secret: "Mã bí mật",
defaultMode: "Chế độ mặc định",
allowOrigin: "Cho phép nguồn gốc",
allowPrivate: "Cho phép mạng riêng",
},
},
tls : {
enable: "Kích hoạt TLS",
usePath: "Sử dụng đường dẫn",
useText: "Sử dụng văn bản",
certPath: "Đường dẫn tệp chứng chỉ",
keyPath: "Đường dẫn tệp khóa",
cert: "Chứng chỉ",
key: "Khóa",
options: "Tùy chọn TLS",
minVer: "Phiên bản Tối thiểu",
maxVer: "Phiên bản Tối đa",
cs: "Các bộ mã hóa",
privKey: "Khóa riêng",
pubKey: "Khóa Công khai",
disableSni: "Tắt SNI",
insecure: "Cho phép Không an toàn",
fragment: "Kiểm tra hệ thống",
fragmentDelay: "Khoảng thời gian hệ thống",
recordFragment: "Kiểm tra bộ nhớ",
store: "Kho lưu trữ gốc",
ktls: "TLS nhân",
kernelTx: "TX",
kernelRx: "RX",
queryServerName: "Tên máy chủ truy vấn ECH",
acme: {
options: "Tùy chọn ACME",
dataDir: "Thư mục Dữ liệu",
defaultDomain: "Tên miền Mặc định",
disableChallenges: "Vô hiệu hóa Thách thức",
httpChallenge: "Vô hiệu hóa Thách thức HTTP",
tlsChallenge: "Vô hiệu hóa Thách thức TLS",
altPorts: "Cổng Thay thế",
altHport: "Cổng HTTP Thay thế",
altTport: "Cổng TLS Thay thế",
caProvider: "Nhà cung cấp CA",
customCa: "Nhà cung cấp CA Tùy chỉnh",
extAcc: "Tài khoản Bên ngoài",
dns01: "Thách thức DNS01",
dns01Provider: "Nhà cung cấp Thách thức DNS01",
dns01Params: {
api_token: "Mã API",
zone_token: "Mã Vùng",
access_key_id: "ID Khóa Truy cập",
access_key_secret: "Bí mật Khóa Truy cập",
region_id: "ID Khu vực",
security_token: "Mã Bảo mật",
username: "Tên đăng nhập",
password: "Mật khẩu",
subdomain: "Tên miền phụ",
server_url: "URL Máy chủ",
},
},
},
stats: {
upload: "Tải lên",
download: "Tải xuống",
volume: "Thể tích",
usage: "Sử dụng",
enable: "Kích hoạt thống kê",
graphTitle: "Biểu đồ lưu lượng",
B: "B",
KB: "KB",
MB: "MB",
GB: "GB",
TB: "TB",
PB: "PB",
p: "ph",
Kp: "Kph",
Mp: "Mph",
Gp: "Gph",
Mbps: "Mbps",
},
date: {
expiry: "Hết hạn",
expired: "Đã hết hạn",
d: "ng",
h: "g",
m: "p",
s: "s",
ms: "ms",
},
}
+635
View File
@@ -0,0 +1,635 @@
export default {
success: "成功",
failed: "失败",
enable: "启用",
disable: "禁用",
none: "无",
all: "全部",
loading: "加载中...",
confirm: "是否确定?",
yes: "确认",
no: "取消",
unlimited: "无限",
type: "类型",
protocol: "协议",
submit: "提交",
reset: "重置",
now: "当前",
network: "网络",
copyToClipboard: "复制到剪贴板",
noData: "无数据!",
invalidLogin: "登录无效!",
online: "在线",
version: "版本",
email: "电子邮件",
commaSeparated: "(逗号分隔)",
count: "计数",
template: "模板",
editor: "编辑器",
error: {
dplData: "重复数据",
core: "Sing-Box 错误",
invalidData: "无效数据",
},
theme: {
light: "浅色",
dark: "深色",
system: "跟随系统",
},
pages: {
login: "登录",
home: "主页",
inbounds: "入站管理",
outbounds: "出站管理",
services: "服务管理",
endpoints: "节点管理",
clients: "用户管理",
rules: "路由列表",
tls: "TLS 设置",
basics: "基础信息",
dns: "DNS",
admins: "管理员",
settings: "设置",
},
main: {
tiles: "信息卡",
gauges: "仪表板",
charts: "图表",
infos: "信息",
gauge: {
cpu: "CPU 仪表",
mem: "RAM 仪表",
dsk: "Disk 仪表",
swp: "Swap 仪表",
},
chart: {
cpu: "CPU 监视器",
mem: "RAM 监视器",
net: "网络带宽",
pnet: "网络数据包",
dio: "Disk I/O",
},
info: {
sys: "系统信息",
sbd: "运行信息",
host: "主机",
cpu: "CPU",
core: "核心",
uptime: "运行时间",
startupTime: "启动时间",
threads: "线程",
memory: "内存",
running: "运行状态"
},
backup: {
title: "备份与恢复",
backup: "下载备份",
restore: "恢复备份",
exclStats: "排除图表数据",
exclChanges: "排除变更数据",
sbConfig: "下载 Sing-Box 配置",
},
stats: {
title: "使用量与统计",
totalUsage: "总用量",
},
},
objects: {
inbound: "入站",
client: "客户端",
outbound: "出站",
endpoint: "节点",
config: "配置",
rule: "规则",
ruleset: "规则集",
service: "服务",
dnsserver: "DNS 服务器",
dnsrule: "DNS规则",
user: "用户",
tag: "标签",
listen: "监听",
dial: "拨号",
tls: "TLS",
multiplex: "多路复用",
transport: "传输",
headers: "标头",
key: "键",
value: "值",
},
actions: {
action: "操作",
add: "添加",
addbulk: "批量添加",
editbulk: "批量编辑",
delbulk: "批量删除",
new: "新建",
edit: "编辑",
del: "删除",
clone: "克隆",
test: "测试",
testAll: "测试全部",
save: "保存",
update: "更新",
submit: "提交",
set: "设置",
generate: "生成",
disable: "禁用",
close: "关闭",
restartApp: "重启面板",
restartSb: "重启 Singbox",
},
login: {
title: "登录",
username: "用户名",
unRules: "用户名不能为空",
password: "密码",
pwRules: "密码不能为空",
},
menu: {
logout: "退出登录",
},
admin: {
changeCred: "更改凭据",
oldPass: "当前密码",
newUname: "新用户名",
newPass: "新密码",
lastLogin: "上次登录",
date: "日期",
time: "时间",
changes: "更改",
actor: "执行者",
key: "键",
action: "操作",
api: {
title: "API 令牌",
msg: "请复制令牌并保存到安全的地方。它将不再显示。",
token: "令牌",
},
},
setting: {
interface: "界面",
sub: "订阅",
addr: "地址",
port: "端口",
webPath: "面板路径",
domain: "域名",
sslKey: "SSL 密钥 (Key) 路径",
sslCert: "SSL 证书 (cert) 路径",
webUri: "面板 URI",
sessionAge: "会话超时时限",
trafficAge: "流量过期时限",
timeLoc: "时区",
subEncode: "启用 Base64 编码",
subInfo: "启用用户信息",
path: "默认路径",
update: "自动更新时间",
subUri: "订阅 URI",
jsonSub: "JSON 订阅",
toDirect: "路由到直连",
toBlock: "路由到阻止",
timestamp: "时间戳",
globalDns: "全局 DNS",
directDns: "直连 DNS",
toDirectDns: "路由到直连 DNS",
jsonSubOptions: "其他选项",
excludePkg: "排除包",
clashSub: "Clash 订阅",
mixedPort: "混合入站端口",
tun: "Tun 入站",
},
client: {
name: "名称",
desc: "描述",
group: "组",
inboundTags: "入站标签",
basics: "基础",
config: "配置",
links: "链接",
external: "外部链接",
sub: "外部订阅",
delayStart: "延迟启动",
autoReset: "自动重置",
resetDays: "重置天数",
nextReset: "下次重置",
},
bulk: {
order: "排序",
random: "随机",
changeLimits: "更改限制",
addInbounds: "添加入站",
removeInbounds: "移除入站",
addDays: "增加天数",
addVolume: "增加流量",
},
types: {
un: "用户名",
pw: "密码",
direct: {
overrideAddr: "覆盖地址",
overridePort: "覆盖端口",
},
hy: {
obfs: "混淆密码",
auth: "认证密码",
hyOptions: "Hysteria 选项",
hy2Options: "Hysteria2 选项",
ignoreBw: "忽略客户端带宽",
},
shdwTls: {
hs: "握手服务器",
addHS: "添加握手服务器",
},
ssh: {
passphrase: "密码短语",
hostKey: "主机密钥",
algorithm: "密钥算法",
clientVer: "客户端版本",
options: "SSH 选项",
},
tor: {
execPath: "可执行文件路径",
dataDir: "数据目录",
extArgs: "额外参数",
},
tuic: {
congControl: "拥塞控制",
authTimeout: "认证超时",
hb: "心跳包",
},
tun: {
addr: "地址",
ifName: "接口名称",
excludeMptcp: "排除 MPTCP",
fallbackRuleIndex: "iproute2 回退规则索引",
},
vless: {
flow: "流控",
udpEnc: "UDP 数据包编码",
},
vmess: {
security: "安全性",
globalPadding: "全局填充",
authLen: "加密长度",
},
wg: {
privKey: "私钥",
pubKey: "对等方公钥",
psk: "预共享密钥",
localIp: "本地 IP 地址",
worker: "工作线程",
ifName: "接口名称",
sysIf: "系统接口",
options: "WireGuard 选项",
allowedIp: "允许的 IP 地址",
peer: "对等体",
peers: "对等体",
},
lb: {
defaultOut: "默认出站",
interruptConn: "中断现有连接",
testUrl: "测试 URL",
interval: "间隔",
tolerance: "容错",
urlTestOptions: "URLTest 选项",
},
ts: {
options: "Tailscale 选项",
stateDir: "状态目录",
authKey: "认证密钥",
relayServer: "中继服务器",
relayServerPort: "中继服务器端口",
relayEndpoints: "中继静态端点",
systemInterface: "系统接口",
sysIfName: "接口名称",
sysIfMtu: "接口 MTU",
controlUrl: "控制 URL",
ephemeral: "临时节点",
hostname: "主机名",
acceptRoutes: "接受路由",
exitNode: "出口节点",
allowLanAccess: "允许 LAN 访问",
advRoutes: "广告路由",
advExitNode: "广告出口节点",
udpTimeout: "UDP 超时",
},
ocm: {
credentialPath: "凭据路径",
usagesPath: "用量统计路径",
users: "用户",
userName: "名称",
userToken: "令牌",
},
ccm: {
credentialPath: "凭据路径",
usagesPath: "用量统计路径",
users: "用户",
userName: "名称",
userToken: "令牌",
},
derp: {
configPath: "配置路径",
verifyClientEndpoint: "验证客户端端点",
verifyClientUrl: "验证客户端 URL",
meshWith: "与其他 DERP 节点网格连接",
meshPsk: "网格预共享密钥",
meshPskFile: "网格预共享密钥文件",
stun: "STUN 服务器",
options: "DERP 选项",
},
naive: {
insecureConcurrency: "不安全并发数",
quic: "QUIC",
quicCongestion: "QUIC 拥塞控制",
udpOverTcp: "UDP over TCP",
},
anytls: {
idleInterval: "空闲会话检查间隔",
idleTimeout: "空闲会话超时",
minIdle: "最小空闲会话数"
},
},
in: {
addr: "地址",
port: "端口",
ssMethod: "方法",
ssManageable: "可管理的",
sSide: "服务器端",
cSide: "客户端",
multiDomain: "多域名",
remark: "备注",
mdOption: "多域名选项",
},
listen: {
options: "监听选项",
tcpOptions: "TCP 选项",
udpOptions: "UDP 选项",
detour: "转发",
detourText: "转发到入站",
disableTcpKeepAlive: "禁用 TCP Keep Alive",
tcpKeepAlive: "TCP Keep Alive",
tcpKeepAliveInterval: "TCP Keep Alive 间隔",
},
dial: {
bindIf: "绑定到网络接口",
bindIp4: "绑定到 IPv4",
bindIp6: "绑定到 IPv6",
bindNoPort: "绑定地址不占端口",
reuseAddr: "重用监听地址",
connTimeout: "连接超时",
disableTcpKeepAlive: "禁用 TCP Keep Alive",
tcpKeepAlive: "TCP Keep Alive",
tcpKeepAliveInterval: "TCP Keep Alive 间隔",
domainResolver: "域名解析器",
options: "拨号选项",
detourText: "转发至出站",
},
transport: {
enable: "启用传输",
host: "主机域名",
hosts: "主机域名列表",
path: "HTTP 请求路径",
httpMethod: "HTTP 请求方法",
idleTimeout: "空闲超时",
pingTimeout: "Ping 超时",
grpcServiceName: "gRPC 服务名称",
grpcPws: "允许无流时保持连接",
},
mux: {
enable: "启用多路复用",
maxConn: "最大连接数",
minStr: "最小流数",
maxStr: "最大流数",
padding: "仅允许填充连接",
enableBrutal: "启用 TCP Brutal",
},
out: {
addr: "服务器地址",
port: "服务器端口",
addUrlTest: "添加 URLTest",
delay: "延迟",
},
rule: {
add: "添加规则",
simple: "简单",
logical: "逻辑",
mode: "模式",
invert: "反选结果",
ipVer: "IP 版本",
domain: "域名",
domainSufix: "域名后缀",
domainKw: "域名关键词",
domainRgx: "域名正则表达式",
ip: "IP CIDR",
privateIp: "匹配非公开 IP",
port: "端口",
portRange: "端口范围",
srcCidr: "源 IP CIDR",
srcPrivateIp: "匹配非公开源 IP",
srcPort: "源端口",
srcPortRange: "源端口范围",
ruleset: "规则集",
rulesetMatchSrc: "规则集 IP CIDR 匹配源 IP",
preferredBy: "优选出站",
interfaceAddr: "接口地址",
options: "规则选项",
domainRules: "域名/IP",
srcIpRules: "源 IP",
srcPortRules: "源端口",
udpDisableDomainUnmapping: "禁用域名解析映射",
udpConnect: "启用 UDP 连接",
udpTimeout: "UDP 超时",
method: "方法",
noDrop: "不丢弃",
sniffer: "嗅探",
timeout: "超时",
strategy: "策略",
etaHint: "每行一项。空行和重复项将被忽略。",
import: {
title: "批量导入规则集",
rulesTitle: "导入规则",
urlsHint: "每行一个 URL。标签由文件名(不含扩展名)决定。",
fileHint: "上传包含 URL 的 .txt 文件,每行一个。",
jsonHint: "粘贴包含 rules 和/或 rule_set 数组的 JSON 对象。可粘贴整个 \"route\" 块:{'{'}...{'}'} 或仅其内容。",
fileJsonHint: "上传包含 route 块的 .json 文件。",
urlHint: "填写 JSON 文件的直链(例如 GitHub raw 链接)。",
preview: "预览",
skipped: "已存在,以灰色显示",
conflict: "检测到冲突",
merge: "合并 — 添加导入的规则(跳过重复的规则集标签)",
replace: "替换 — 删除现有规则与规则集后重新导入",
pasteUrls: "粘贴 URL",
uploadTxt: "上传 .txt",
uploadFile: "上传文件",
fromUrl: "通过链接",
selectTxt: "选择 .txt 文件",
selectJson: "选择 .json 文件",
parse: "解析",
conflictDesc: "配置中已有 {rules} 条规则和 {rulesets} 个规则集。请选择操作:",
finalOutbound: "默认出站(final",
applyFinal: "设为默认出站",
errNoArrays: '未找到 "rules" 或 "rule_set" 数组。',
errJsonParse: "JSON 解析错误:{message}",
errNoArraysFetched: '获取的 JSON 中未找到 "rules" 或 "rule_set"。',
errFetch: "获取失败:{message}",
errNoFile: "未选择文件。",
errNoArraysInFile: '文件中未找到 "rules" 或 "rule_set"。',
},
},
ruleset: {
add: "添加规则集",
format: "数据格式",
interval: "更新间隔",
remote: "远程",
local: "本地",
},
dns: {
add: "添加 DNS 服务器",
title: "DNS 服务器",
final: "最终",
server: "服务器",
firstServer: "首选服务器",
cacheCapacity: "缓存容量",
disableCache: "禁用缓存",
disableExpire: "禁用过期",
independentCache: "独立缓存",
reverseMapping: "反向映射",
domainStrategy: "域名解析策略",
local: { preferGo: "优先使用 Go" },
rule: {
add: "添加 DNS 规则",
title: "DNS 规则",
inet4Range: "IPv4 范围",
inet6Range: "IPv6 范围",
acceptDefault: "接受默认",
action: {
title: "操作",
route: "路由",
routeOptions: "路由选项",
reject: "拒绝",
predefined: "预定义",
rewriteTtl: "重写 TTL",
clientSubnet: "客户端子网",
rcode: "响应码",
rcodes: {
noError: "正常",
formerr: "请求错误",
servFail: "服务器故障",
nxDomain: "未找到",
refused: "被拒绝",
notImp: "未实现"
},
answer: "回答",
ns: "名称服务器",
extra: "附加"
}
}
},
basic: {
log: {
title: "日志",
level: "级别",
output: "输出",
timestamp: "启用时间戳",
},
routing: {
title: "路由",
defaultOut: "默认出站",
defaultIf: "默认网卡",
defaultRm: "默认路由标记",
defaultDns: "默认 DNS 解析器",
autoBind: "自动绑定网卡",
},
exp: {
storeFakeIp: "持久化 Fake-IP",
extController: "外部控制器",
extUi: "外部界面",
extUiDownloadUrl: "界面下载地址",
extUiDownloadDetour: "界面下载绕行",
secret: "密钥",
defaultMode: "默认模式",
allowOrigin: "允许来源",
allowPrivate: "允许私有网络",
},
},
tls : {
enable: "启用 TLS",
usePath: "使用外部路径",
useText: "使用文件内容",
certPath: "证书文件路径",
keyPath: "私钥文件路径",
cert: "证书文件内容",
key: "私钥文件内容",
options: "TLS 选项",
minVer: "最低版本",
maxVer: "最高版本",
cs: "密码套件",
privKey: "私钥",
pubKey: "公钥",
disableSni: "禁用 SNI",
insecure: "允许不安全",
fragment: "启用",
fragmentDelay: "启用后延迟",
recordFragment: "启用",
store: "根证书库",
ktls: "内核 TLS",
kernelTx: "发送",
kernelRx: "接收",
queryServerName: "ECH 查询服务器名称",
acme: {
options: "ACME 选项",
dataDir: "数据目录",
defaultDomain: "默认域名",
disableChallenges: "禁用挑战",
httpChallenge: "禁用 HTTP 挑战",
tlsChallenge: "禁用 TLS 挑战",
altPorts: "替代端口",
altHport: "替代 HTTP 端口",
altTport: "替代 TLS 端口",
caProvider: "CA 提供商",
customCa: "自定义 CA 提供商",
extAcc: "外部账户",
dns01: "DNS01 挑战",
dns01Provider: "DNS01 挑战提供商",
dns01Params: {
api_token: "API 令牌",
zone_token: "Zone 令牌",
access_key_id: "访问密钥 ID",
access_key_secret: "访问密钥密文",
region_id: "区域 ID",
security_token: "安全令牌",
username: "用户名",
password: "密码",
subdomain: "子域名",
server_url: "服务器 URL",
},
},
},
stats: {
upload: "上传",
download: "下载",
volume: "流量",
usage: "已用",
enable: "启用统计",
graphTitle: "流量图表",
B: "B",
KB: "KB",
MB: "MB",
GB: "GB",
TB: "TB",
PB: "PB",
p: "p",
Kp: "Kp",
Mp: "Mp",
Gp: "Gp",
Mbps: "Mbps",
},
date: {
expiry: "到期",
expired: "已到期",
d: "天",
h: "时",
m: "分",
s: "秒",
ms: "毫秒",
},
}
+635
View File
@@ -0,0 +1,635 @@
export default {
success: "成功",
failed: "失敗",
enable: "啟用",
disable: "禁用",
none: "無",
all: "全部",
loading: "加載中...",
confirm: "是否確定?",
yes: "確認",
no: "取消",
unlimited: "無限",
type: "類型",
protocol: "協定",
submit: "提交",
reset: "重置",
now: "當前",
network: "網絡",
copyToClipboard: "復製到剪貼板",
noData: "無數據!",
invalidLogin: "登錄無效!",
online: "在線",
version: "版本",
email: "電子郵件",
commaSeparated: "(逗號分隔)",
count: "計數",
template: "模板",
editor: "編輯器",
error: {
dplData: "重複數據",
core: "Sing-Box 錯誤",
invalidData: "無效數據",
},
theme: {
light: "明亮",
dark: "暗黑",
system: "系統",
},
pages: {
login: "登錄",
home: "主頁",
inbounds: "入站管理",
outbounds: "出站管理",
services: "服務管理",
endpoints: "端點管理",
clients: "用戶管理",
rules: "路由列表",
tls: "TLS 設置",
basics: "基礎信息",
dns: "DNS",
admins: "管理員",
settings: "設置",
},
main: {
tiles: "信息卡",
gauges: "儀表板",
charts: "圖表",
infos: "信息",
gauge: {
cpu: "CPU 儀表",
mem: "RAM 儀表",
dsk: "Disk 儀表",
swp: "Swap 儀表",
},
chart: {
cpu: "CPU 監視器",
mem: "RAM 監視器",
net: "網絡帶寬",
pnet: "網絡數據包",
dio: "Disk I/O",
},
info: {
sys: "系統信息",
sbd: "運行信息",
host: "主機",
cpu: "CPU",
core: "核心",
uptime: "運行時間",
startupTime: "啟動時間",
threads: "線程",
memory: "內存",
running: "運行狀態"
},
backup: {
title: "備份與恢復",
backup: "下載備份",
restore: "恢復備份",
exclStats: "排除圖表記錄",
exclChanges: "排除更改記錄",
sbConfig: "下載 Sing-Box 配置",
},
stats: {
title: "使用量與統計",
totalUsage: "總用量",
},
},
objects: {
inbound: "入站",
client: "客戶端",
outbound: "出站",
endpoint: "端點",
config: "配置",
rule: "規則",
ruleset: "規則集",
service: "服務",
dnsserver: "DNS 服務器",
dnsrule: "DNS 規則",
user: "用戶",
tag: "標簽",
listen: "聽",
dial: "撥號",
tls: "TLS",
multiplex: "多路復用",
transport: "傳輸",
headers: "方法",
key: "鑰匙",
value: "價值",
},
actions: {
action: "操作",
add: "添加",
addbulk: "批量添加",
editbulk: "批量編輯",
delbulk: "批量刪除",
new: "新建",
edit: "編輯",
del: "刪除",
clone: "克隆",
test: "測試",
testAll: "測試全部",
save: "保存",
update: "更新",
submit: "提交",
set: "設置",
generate: "生成",
disable: "禁用",
close: "關閉",
restartApp: "重啟面板",
restartSb: "重啟 Singbox",
},
login: {
title: "登錄",
username: "用戶名",
unRules: "用戶名不能為空",
password: "密碼",
pwRules: "密碼不能為空",
},
menu: {
logout: "退出登錄",
},
admin: {
changeCred: "更改憑據",
oldPass: "當前密碼",
newUname: "新用戶名",
newPass: "新密碼",
lastLogin: "上次登入",
date: "日期",
time: "時間",
changes: "更改",
actor: "執行者",
key: "鍵",
action: "操作",
api: {
title: "API 憑據",
msg: "請複製下面的憑據並儲存在安全的地方。它將不再顯示。",
token: "憑據",
},
},
setting: {
interface: "界面",
sub: "訂閱",
addr: "地址",
port: "端口",
webPath: "基本 URI",
domain: "域名",
sslKey: "SSL 密鑰 (Key) 路徑",
sslCert: "SSL 證書 (cert) 路徑",
webUri: "面板 URI",
sessionAge: "會話最大連接數",
trafficAge: "流量最大年齡",
timeLoc: "時區",
subEncode: "啟用編碼",
subInfo: "啟用用戶信息",
path: "默認路徑",
update: "自動更新時間",
subUri: "訂閱 URL",
jsonSub: "JSON 訂閱",
toDirect: "路由到直連",
toBlock: "路由到阻止",
timestamp: "時間戳",
globalDns: "全局 DNS",
directDns: "直連 DNS",
toDirectDns: "路由到直連 DNS",
jsonSubOptions: "其他選項",
excludePkg: "排除包",
clashSub: "Clash 訂閱",
mixedPort: "混合入站端口",
tun: "Tun 入站",
},
client: {
name: "名稱",
desc: "描述",
group: "組",
inboundTags: "入站標簽",
basics: "基礎",
config: "配置",
links: "鏈接",
external: "外部鏈接",
sub: "外部訂閱",
delayStart: "延遲啟動",
autoReset: "自動重置",
resetDays: "重置天數",
nextReset: "下次重置",
},
bulk: {
order: "排序",
random: "隨機",
changeLimits: "變更限制",
addInbounds: "添加入站",
removeInbounds: "移除入站",
addDays: "增加天數",
addVolume: "增加流量",
},
types: {
un: "用戶名",
pw: "密碼",
direct: {
overrideAddr: "覆蓋地址",
overridePort: "覆蓋端口",
},
hy: {
obfs: "混淆密碼",
auth: "驗證密碼",
hyOptions: "Hysteria 選項",
hy2Options: "Hysteria2 選項",
ignoreBw: "忽略客戶端帶寬",
},
shdwTls: {
hs: "握手服務器",
addHS: "添加握手服務器",
},
ssh: {
passphrase: "密語",
hostKey: "主機密鑰",
algorithm: "密鑰算法",
clientVer: "客戶端版本",
options: "SSH 選項",
},
tor: {
execPath: "可執行文件路徑",
dataDir: "數據目錄",
extArgs: "額外參數",
},
tuic: {
congControl: "擁塞控制",
authTimeout: "身份驗證超時",
hb: "心跳",
},
tun: {
addr: "地址",
ifName: "介面名稱",
excludeMptcp: "排除 MPTCP",
fallbackRuleIndex: "iproute2 回退規則索引",
},
vless: {
flow: "流量",
udpEnc: "UDP 封包編碼",
},
vmess: {
security: "安全性",
globalPadding: "全局填充",
authLen: "加密長度",
},
wg: {
privKey: "私鑰",
pubKey: "對等方公鑰",
psk: "預共享密鑰",
localIp: "本地 IP",
worker: "工作線程",
ifName: "介面名稱",
sysIf: "系統介面",
options: "Wireguard 選項",
allowedIp: "允許的 IP",
peer: "對等方",
peers: "對等方",
},
lb: {
defaultOut: "默認外部",
interruptConn: "中斷現有連接",
testUrl: "測試 URL",
interval: "間隔",
tolerance: "容忍度",
urlTestOptions: "URLTest 選項"
},
ts: {
options: "Tailscale 選項",
stateDir: "狀態目錄",
authKey: "授權密鑰",
relayServer: "轉發伺服器",
relayServerPort: "轉發伺服器端口",
relayEndpoints: "轉發靜態端點",
systemInterface: "系統介面",
sysIfName: "介面名稱",
sysIfMtu: "介面 MTU",
controlUrl: "控制 URL",
ephemeral: "臨時節點",
hostname: "主機名",
acceptRoutes: "接受路由",
exitNode: "出口節點",
allowLanAccess: "允許 LAN 訪問",
advRoutes: "廣告路由",
advExitNode: "廣告出口節點",
udpTimeout: "UDP 超時",
},
ocm: {
credentialPath: "憑證路徑",
usagesPath: "用量統計路徑",
users: "用戶",
userName: "名稱",
userToken: "令牌",
},
ccm: {
credentialPath: "憑證路徑",
usagesPath: "用量統計路徑",
users: "用戶",
userName: "名稱",
userToken: "令牌",
},
derp: {
configPath: "配置路徑",
verifyClientEndpoint: "驗證客戶端端點",
verifyClientUrl: "驗證客戶端 URL",
meshWith: "網狀連接",
meshPsk: "網狀 PSK",
meshPskFile: "網狀 PSK 文件",
stun: "STUN 服務器",
options: "DERP 選項",
},
naive: {
insecureConcurrency: "不安全並發數",
quic: "QUIC",
quicCongestion: "QUIC 擁塞控制",
udpOverTcp: "UDP over TCP",
},
anytls: {
idleInterval: "閒置會話檢查間隔",
idleTimeout: "閒置會話逾時",
minIdle: "最小閒置會話數"
},
},
in: {
addr: "地址",
port: "端口",
ssMethod: "方法",
ssManageable: "可管理的",
sSide: "服務器端",
cSide: "客戶端",
multiDomain: "多域名",
remark: "備註",
mdOption: "多域名選項",
},
listen: {
options: "監聽選項",
tcpOptions: "TCP 選項",
udpOptions: "UDP 選項",
detour: "繞道",
detourText: "轉發到入站",
disableTcpKeepAlive: "停用 TCP Keep Alive",
tcpKeepAlive: "TCP Keep Alive",
tcpKeepAliveInterval: "TCP Keep Alive 間隔",
},
dial: {
bindIf: "綁定到網路接口",
bindIp4: "綁定到 IPv4",
bindIp6: "綁定到 IPv6",
bindNoPort: "綁定地址不佔用端口",
reuseAddr: "重用監聽地址",
connTimeout: "連接超時",
disableTcpKeepAlive: "停用 TCP Keep Alive",
tcpKeepAlive: "TCP Keep Alive",
tcpKeepAliveInterval: "TCP Keep Alive 間隔",
domainResolver: "域名解析器",
options: "撥號選項",
detourText: "轉寄至出站",
},
transport: {
enable: "啟用傳輸",
host: "主機",
hosts: "主機列表",
path: "路徑",
httpMethod: "請求方法",
idleTimeout: "閒置超時",
pingTimeout: "Ping 超時",
grpcServiceName: "服務名稱",
grpcPws: "允許無流",
},
mux: {
enable: "啟用多路徑",
maxConn: "最大連接數",
minStr: "最小串流數",
maxStr: "最大串流數",
padding: "僅填充",
enableBrutal: "啟用暴力",
},
out: {
addr: "伺服器地址",
port: "伺服器端口",
addUrlTest: "新增 URLTest",
delay: "延遲",
},
rule: {
add: "添加規則",
simple: "簡單",
logical: "邏輯",
mode: "模式",
invert: "反轉",
ipVer: "IP 版本",
domain: "域名",
domainSufix: "域名後綴",
domainKw: "域名關鍵詞",
domainRgx: "域名正則表達式",
ip: "IP CIDR",
privateIp: "無效 IP 範圍",
port: "端口",
portRange: "端口範圍",
srcCidr: "源 IP CIDR",
srcPrivateIp: "無效源 IP",
srcPort: "源端口",
srcPortRange: "源端口範圍",
ruleset: "規則集",
rulesetMatchSrc: "規則集 IP 範圍匹配源",
preferredBy: "優選出站",
interfaceAddr: "介面地址",
options: "規則選項",
domainRules: "域名/IP",
srcIpRules: "源 IP",
srcPortRules: "源端口",
udpDisableDomainUnmapping: "禁用域名解析映射",
udpConnect: "啟用 UDP 連接",
udpTimeout: "UDP 超時",
method: "方法",
noDrop: "不丟弃",
sniffer: "嗅探",
timeout: "超時",
strategy: "策略",
etaHint: "每行一項。空白行與重複項目將被略過。",
import: {
title: "批次匯入規則集",
rulesTitle: "匯入規則",
urlsHint: "每行一個 URL。標籤由檔名(不含副檔名)決定。",
fileHint: "上傳含 URL 的 .txt 檔案,每行一個。",
jsonHint: "貼上含 rules 及/或 rule_set 陣列的 JSON 物件。可貼上整個 \"route\" 區塊:{'{'}...{'}'} 或僅其內容。",
fileJsonHint: "上傳含 route 區塊的 .json 檔案。",
urlHint: "填寫 JSON 檔案的直連(例如 GitHub raw 連結)。",
preview: "預覽",
skipped: "已存在,以灰色顯示",
conflict: "偵測到衝突",
merge: "合併 — 加入匯入的規則(略過重複的規則集標籤)",
replace: "取代 — 刪除現有規則與規則集後重新匯入",
pasteUrls: "貼上 URL",
uploadTxt: "上傳 .txt",
uploadFile: "上傳檔案",
fromUrl: "透過連結",
selectTxt: "選擇 .txt 檔案",
selectJson: "選擇 .json 檔案",
parse: "解析",
conflictDesc: "設定中已有 {rules} 條規則與 {rulesets} 個規則集。請選擇操作:",
finalOutbound: "預設出站(final",
applyFinal: "設為預設出站",
errNoArrays: '找不到 "rules" 或 "rule_set" 陣列。',
errJsonParse: "JSON 解析錯誤:{message}",
errNoArraysFetched: '取得的 JSON 中找不到 "rules" 或 "rule_set"。',
errFetch: "取得失敗:{message}",
errNoFile: "未選擇檔案。",
errNoArraysInFile: '檔案中找不到 "rules" 或 "rule_set"。',
},
},
ruleset: {
add: "添加規則集",
format: "數據格式",
interval: "更新間隔",
remote: "遠端",
local: "本地",
},
dns: {
add: "添加 DNS 服務器",
title: "DNS 服務器",
final: "最終",
server: "服務器",
firstServer: "首選服務器",
cacheCapacity: "快取容量",
disableCache: "停用快取",
disableExpire: "停用過期",
independentCache: "獨立快取",
reverseMapping: "反向映射",
domainStrategy: "域名策略",
local: { preferGo: "優先使用 Go" },
rule: {
add: "添加 DNS 規則",
title: "DNS 規則",
inet4Range: "IPv4 範圍",
inet6Range: "IPv6 範圍",
acceptDefault: "接受默認",
action: {
title: "操作",
route: "路由",
routeOptions: "路由選項",
reject: "拒絕",
predefined: "預設",
rewriteTtl: "重寫 TTL",
clientSubnet: "用戶端子網",
rcode: "回應碼",
rcodes: {
noError: "正常",
formerr: "請求錯誤",
servFail: "伺服器故障",
nxDomain: "未找到",
refused: "被拒絕",
notImp: "尚未實作"
},
answer: "回應",
ns: "名稱伺服器",
extra: "額外資訊"
}
},
},
basic: {
log: {
title: "日誌",
level: "級別",
output: "輸出",
timestamp: "啟用時間戳記",
},
routing: {
title: "路由",
defaultOut: "默認外部",
defaultIf: "默認網卡",
defaultRm: "默認路由標記",
defaultDns: "默認 DNS 解析器",
autoBind: "自動綁定網卡",
},
exp: {
storeFakeIp: "存儲假 IP",
extController: "外部控制器",
extUi: "外部介面",
extUiDownloadUrl: "介面下載網址",
extUiDownloadDetour: "介面下載繞行",
secret: "密鑰",
defaultMode: "預設模式",
allowOrigin: "允許來源",
allowPrivate: "允許私人網路",
},
},
tls : {
enable: "啟用 TLS",
usePath: "使用外部路徑",
useText: "使用文件內容",
certPath: "證書文件路徑",
keyPath: "私鑰文件路徑",
cert: "證書文件內容",
key: "私鑰文件內容",
options: "TLS 選項",
minVer: "最低版本",
maxVer: "最高版本",
cs: "加密套件",
privKey: "私鑰",
pubKey: "公鑰",
disableSni: "停用 SNI",
insecure: "允許不安全連線",
fragment: "分段",
fragmentDelay: "分段回應延遲",
recordFragment: "多筆記錄分段",
store: "根憑證庫",
ktls: "內核 TLS",
kernelTx: "發送",
kernelRx: "接收",
queryServerName: "ECH 查詢伺服器名稱",
acme: {
options: "ACME 選項",
dataDir: "數據目錄",
defaultDomain: "默認域名",
disableChallenges: "禁用挑戰",
httpChallenge: "禁用 HTTP 挑戰",
tlsChallenge: "禁用 TLS 挑戰",
altPorts: "替代端口",
altHport: "替代 HTTP 端口",
altTport: "替代 TLS 端口",
caProvider: "CA 提供商",
customCa: "自定義 CA 提供商",
extAcc: "外部賬戶",
dns01: "DNS01 挑戰",
dns01Provider: "DNS01 挑戰提供商",
dns01Params: {
api_token: "API 令牌",
zone_token: "Zone 令牌",
access_key_id: "存取金鑰 ID",
access_key_secret: "存取金鑰密文",
region_id: "區域 ID",
security_token: "安全令牌",
username: "用戶名",
password: "密碼",
subdomain: "子網域",
server_url: "伺服器 URL",
},
},
},
stats: {
upload: "上傳",
download: "下載",
volume: "流量",
usage: "已用",
enable: "啟用統計",
graphTitle: "流量圖表",
B: "B",
KB: "KB",
MB: "MB",
GB: "GB",
TB: "TB",
PB: "PB",
p: "p",
Kp: "Kp",
Mp: "Mp",
Gp: "Gp",
Mbps: "Mbps",
},
date: {
expiry: "到期",
expired: "已到期",
d: "天",
h: "時",
m: "分",
s: "秒",
ms: "毫秒",
},
}
+55
View File
@@ -0,0 +1,55 @@
/**
* main.ts
*
* Bootstraps Vuetify and other plugins then mounts the App`
*/
// Composables
import { createApp, ref } from 'vue'
// Components
import App from './App.vue'
// Use router
import router from './router'
// Store
import store from './store'
// Plugins
import { registerPlugins } from '@/plugins'
// Locale
import { i18n } from '@/locales'
import Vue3PersianDatetimePicker from 'vue3-persian-datetime-picker'
// Notivue
import { createNotivue } from 'notivue'
import 'notivue/notification.css'
import 'notivue/animations.css'
const notivue = createNotivue({
position: 'bottom-center',
limit: 4,
enqueue: false,
avoidDuplicates: true,
notifications: {
global: {
duration: 3000
}
},
})
const loading = ref(false)
const app = createApp(App)
app.provide('loading', loading)
registerPlugins(app)
app
.use(router)
.use(store)
.use(i18n)
.use(notivue)
.component('DatePicker', Vue3PersianDatetimePicker)
.mount('#app')
+57
View File
@@ -0,0 +1,57 @@
import axios from 'axios'
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
axios.defaults.baseURL = "./"
const pendingRequests = new Map()
axios.interceptors.request.use(
(config) => {
// Generate a unique key for the request
const requestKey = `${config.method}:${config.url}`
// Check if there is already a pending request with the same key
if (pendingRequests.has(requestKey)) {
const cancelSource = pendingRequests.get(requestKey)
cancelSource.cancel('Duplicate request cancelled')
}
// Create a new cancel token for the request
const cancelSource = axios.CancelToken.source()
config.cancelToken = cancelSource.token
// Store the cancel token in the pending requests map
pendingRequests.set(requestKey, cancelSource)
if (config.data instanceof FormData) {
config.headers['Content-Type'] = 'multipart/form-data'
}
return config
},
(error) => Promise.reject(error),
)
axios.interceptors.response.use(
(response) => {
// Remove the request from the pending requests map
const requestKey = `${response.config.method}:${response.config.url}`
pendingRequests.delete(requestKey)
return response
},
(error) => {
if (axios.isCancel(error)) {
// Handle duplicate request cancellation here if needed
console.warn(error.message)
} else {
// Remove the request from the pending requests map on error
const requestKey = `${error.config.method}:${error.config.url}`
pendingRequests.delete(requestKey)
}
return Promise.reject(error)
}
)
const api = axios.create()
export default api
+88
View File
@@ -0,0 +1,88 @@
import api from './api'
import { i18n } from '@/locales'
import router from '@/router'
import { push } from 'notivue'
export interface Msg {
success: boolean
msg: string
obj: any | null
}
function _handleMsg(msg: any): void {
if (!isMsg(msg)) {
return
}
if(msg.msg){
if (!msg.success && msg.msg == "Invalid login") {
push.error({
title: i18n.global.t('invalidLogin'),
})
logout()
return
}
if (msg.success) {
push.success({
message: i18n.global.t('success') + ": " + i18n.global.t('actions.' + msg.msg),
})
} else {
push.error({
title: i18n.global.t('failed'),
message: msg.msg
})
}
}
}
export const logout = async () => {
const response = await HttpUtils.get('api/logout')
if(response.success){
router.push('/login')
}
}
function _respToMsg(resp: any): Msg {
const data = resp.data
if (data == null) {
return { success: true, msg: "", obj: null }
} else if (isMsg(data)) {
if (data.hasOwnProperty('success')) {
return { success: data.success, msg: data.msg, obj: data.obj || null }
} else {
return data
}
} else {
return { success: false, msg: `unknown data: ${data}`, obj: null }
}
}
function isMsg(obj: any): obj is Msg {
return Object.hasOwn(obj,'success') && Object.hasOwn(obj,'msg') && Object.hasOwn(obj, 'obj')
}
const HttpUtils = {
async get(url: string, data: object = {}, options: any[] = []): Promise<Msg> {
let msg: Msg
try {
const resp = await api.get(url, { params: data, ...options })
msg = _respToMsg(resp)
} catch (e: any) {
msg = { success: false, msg: e.toString(), obj: null }
}
_handleMsg(msg)
return msg
},
async post(url: string, data: object | null, options: any = undefined): Promise<Msg> {
let msg: Msg
try {
const resp = await api.post(url, data, options)
msg = _respToMsg(resp)
} catch (e: any) {
msg = { success: false, msg: e.toString(), obj: null }
}
_handleMsg(msg)
return msg
},
}
export default HttpUtils

Some files were not shown because too many files have changed in this diff Show More