Feature for #844

This commit is contained in:
Donald Zou
2025-09-08 15:12:16 +08:00
parent 8bbb4a48f7
commit b3889bb1e3
5 changed files with 504 additions and 34 deletions

View File

@@ -39,6 +39,7 @@ from logging.config import dictConfig
from modules.DashboardClients import DashboardClients from modules.DashboardClients import DashboardClients
from modules.DashboardPlugins import DashboardPlugins from modules.DashboardPlugins import DashboardPlugins
from modules.DashboardWebHooks import DashboardWebHooks from modules.DashboardWebHooks import DashboardWebHooks
from modules.NewConfigurationTemplates import NewConfigurationTemplates
dictConfig({ dictConfig({
'version': 1, 'version': 1,
@@ -214,12 +215,40 @@ def API_SignOut():
session.clear() session.clear()
return resp return resp
@app.route(f'{APP_PREFIX}/api/getWireguardConfigurations', methods=["GET"]) @app.get(f'{APP_PREFIX}/api/getWireguardConfigurations')
def API_getWireguardConfigurations(): def API_getWireguardConfigurations():
InitWireguardConfigurationsList() InitWireguardConfigurationsList()
return ResponseObject(data=[wc for wc in WireguardConfigurations.values()]) return ResponseObject(data=[wc for wc in WireguardConfigurations.values()])
@app.route(f'{APP_PREFIX}/api/addWireguardConfiguration', methods=["POST"]) @app.get(f'{APP_PREFIX}/api/newConfigurationTemplates')
def API_NewConfigurationTemplates():
return ResponseObject(data=NewConfigurationTemplates.GetTemplates())
@app.get(f'{APP_PREFIX}/api/newConfigurationTemplates/createTemplate')
def API_NewConfigurationTemplates_CreateTemplate():
return ResponseObject(data=NewConfigurationTemplates.CreateTemplate().model_dump())
@app.post(f'{APP_PREFIX}/api/newConfigurationTemplates/updateTemplate')
def API_NewConfigurationTemplates_UpdateTemplate():
data = request.get_json()
template = data.get('Template', None)
if not template:
return ResponseObject(False, "Please provide template")
status, msg = NewConfigurationTemplates.UpdateTemplate(template)
return ResponseObject(status, msg)
@app.post(f'{APP_PREFIX}/api/newConfigurationTemplates/deleteTemplate')
def API_NewConfigurationTemplates_DeleteTemplate():
data = request.get_json()
template = data.get('Template', None)
if not template:
return ResponseObject(False, "Please provide template")
status, msg = NewConfigurationTemplates.DeleteTemplate(template)
return ResponseObject(status, msg)
@app.post(f'{APP_PREFIX}/api/addWireguardConfiguration')
def API_addWireguardConfiguration(): def API_addWireguardConfiguration():
data = request.get_json() data = request.get_json()
requiredKeys = [ requiredKeys = [
@@ -1625,6 +1654,7 @@ AllPeerJobs: PeerJobs = PeerJobs(DashboardConfig, WireguardConfigurations)
DashboardLogger: DashboardLogger = DashboardLogger() DashboardLogger: DashboardLogger = DashboardLogger()
DashboardPlugins: DashboardPlugins = DashboardPlugins(app, WireguardConfigurations) DashboardPlugins: DashboardPlugins = DashboardPlugins(app, WireguardConfigurations)
DashboardWebHooks: DashboardWebHooks = DashboardWebHooks(DashboardConfig) DashboardWebHooks: DashboardWebHooks = DashboardWebHooks(DashboardConfig)
NewConfigurationTemplates: NewConfigurationTemplates = NewConfigurationTemplates()
InitWireguardConfigurationsList(startup=True) InitWireguardConfigurationsList(startup=True)

View File

@@ -0,0 +1,88 @@
import uuid
from pydantic import BaseModel, field_serializer
import sqlalchemy as db
from .ConnectionString import ConnectionString
class NewConfigurationTemplate(BaseModel):
TemplateID: str = ''
Subnet: str = ''
ListenPortStart: int = 0
ListenPortEnd: int = 0
Notes: str = ""
class NewConfigurationTemplates:
def __init__(self):
self.engine = db.create_engine(ConnectionString("wgdashboard"))
self.metadata = db.MetaData()
self.templatesTable = db.Table(
'NewConfigurationTemplates', self.metadata,
db.Column('TemplateID', db.String(255), primary_key=True),
db.Column('Subnet', db.String(255)),
db.Column('ListenPortStart', db.Integer),
db.Column('ListenPortEnd', db.Integer),
db.Column('Notes', db.Text),
)
self.metadata.create_all(self.engine)
self.Templates: list[NewConfigurationTemplate] = []
self.__getTemplates()
def GetTemplates(self):
self.__getTemplates()
return list(map(lambda x : x.model_dump(), self.Templates))
def __getTemplates(self):
with self.engine.connect() as conn:
templates = conn.execute(
self.templatesTable.select()
).mappings().fetchall()
self.Templates.clear()
self.Templates = [NewConfigurationTemplate(**template) for template in templates]
def CreateTemplate(self) -> NewConfigurationTemplate:
return NewConfigurationTemplate(TemplateID=str(uuid.uuid4()))
def SearchTemplate(self, template: NewConfigurationTemplate):
try:
first = next(filter(lambda x : x.TemplateID == template.TemplateID, self.Templates))
except StopIteration:
return None
return first
def UpdateTemplate(self, template: dict[str, str]) -> tuple[bool, str] | tuple[bool, None]:
try:
template = NewConfigurationTemplate(**template)
with self.engine.begin() as conn:
if self.SearchTemplate(template):
conn.execute(
self.templatesTable.update().values(
template.model_dump(exclude={'TemplateID'})
).where(
self.templatesTable.c.TemplateID == template.TemplateID
)
)
else:
conn.execute(
self.templatesTable.insert().values(
template.model_dump()
)
)
self.__getTemplates()
except Exception as e:
return False, str(e)
return True, None
def DeleteTemplate(self, template: dict[str, str]) -> tuple[bool, str] | tuple[bool, None]:
try:
template = NewConfigurationTemplate(**template)
with self.engine.begin() as conn:
conn.execute(
self.templatesTable.delete().where(
self.templatesTable.c.TemplateID == template.TemplateID
)
)
self.__getTemplates()
except Exception as e:
return False, str(e)
return True, None

View File

@@ -0,0 +1,250 @@
<script setup lang="ts">
import LocaleText from "@/components/text/localeText.vue";
import {computed, onMounted, reactive, ref, watch} from "vue";
import {containsCidr, expandCidr, mergeCidr, parseCidr} from "cidr-tools";
import {fetchPost} from "@/utilities/fetch.js"
const props = defineProps(['template', 'edit', 'isNew', 'peersCount'])
import {WireguardConfigurationsStore} from "@/stores/WireguardConfigurationsStore";
const store = WireguardConfigurationsStore()
const edit = ref(false)
if (props.edit){
edit.value = true
}
const data = ref({...props.template})
const peersCount = ref(256)
const groups = ref([])
const show = ref(20)
const emits = defineEmits(['subnet', 'port', 'update', 'remove'])
const selectedSubnet = ref(undefined)
const selectedPort = ref(undefined)
const ports = ref([])
const availableSubnets = () => {
groups.value = []
if (props.template.Subnet){
let templateExpand = new Set([...expandCidr(props.template.Subnet)])
if (props.peersCount && props.peersCount > 0){
for (let c of store.Configurations){
let address = c.Address.replace(" ", "").split(",")
for (let a of address){
if (containsCidr(props.template.Subnet, a)){
templateExpand = templateExpand.difference(
new Set([...expandCidr(a)])
)
}
}
}
let groupsCount = Math.floor(templateExpand.size / props.peersCount)
let sliced = 0
templateExpand = Array.from(templateExpand)
for (let g = 0; g < (groupsCount > 10 ? 10 : groupsCount); g++){
groups.value.push(mergeCidr(templateExpand.slice(sliced, sliced + props.peersCount)))
sliced += props.peersCount
}
}
}
}
const availablePorts = () => {
if (props.template.ListenPortStart && props.template.ListenPortEnd){
let start = props.template.ListenPortStart
let end = props.template.ListenPortEnd
if (start > end){
start = props.template.ListenPortEnd
end = props.template.ListenPortStart
}
let p = new Set(Array.from(
{
length: end - start + 1
}, (val, index) => start + index
))
ports.value = [...p.difference(new Set(store.Configurations.map(
c => Number(c.ListenPort)
)))]
}
}
onMounted(() => {
if (!props.isNew){
availableSubnets()
availablePorts()
}
})
watch(() => props.peersCount, () => {
availableSubnets()
})
watch(selectedSubnet, () => {
emits("subnet", selectedSubnet.value)
})
watch(selectedPort, () => {
emits("port", selectedPort.value)
})
watch(() => props.template, () => {
availableSubnets()
availablePorts()
}, {
deep: true
})
const readyToSave = computed(() => {
try{
const {start, end} = parseCidr(data.value.Subnet)
if (end - start >= 1000000n) {
throw new Error("Too many IPs");
}
return data.value.Subnet && data.value.ListenPortStart && data.value.ListenPortEnd && (data.value.ListenPortEnd >= data.value.ListenPortStart)
}catch (e){
return false
}
})
const saveTemplate = async () => {
await fetchPost("/api/newConfigurationTemplates/updateTemplate", {
Template: data.value
}, (res) => {
if (res.status){
emits('update', data.value)
edit.value = false
}
})
}
const deleteTemplate = async () => {
await fetchPost("/api/newConfigurationTemplates/deleteTemplate", {
Template: data.value
}, (res) => {
if (res.status){
emits('remove', data)
}
})
}
</script>
<template>
<div class="card rounded-3">
<div class="card-body ">
<div class="row">
<div class="col-sm">
<div class="d-flex flex-column gap-2">
<div class="d-flex align-items-center">
<label class="text-muted">
<small><LocaleText t="Subnet"></LocaleText></small>
</label>
<p class="mb-0 ms-auto" v-if="!edit"><small>{{ template.Subnet }}</small></p>
<input class="form-control-sm form-control rounded-3 w-auto ms-auto" v-model="data.Subnet" v-else>
</div>
<div class="d-flex gap-2 flex-column" v-if="!edit">
<label class="text-muted d-flex align-items-center gap-1" style="white-space: nowrap">
<small><LocaleText t="Available Subnets"></LocaleText></small>
<span class="badge rounded-pill text-bg-success ms-auto">
{{ groups.length }}
</span>
</label>
<select
v-model="selectedSubnet"
class="form-select form-select-sm rounded-3 w-100 ms-auto">
<option :value="undefined" disabled>
<LocaleText t="Select..."></LocaleText>
</option>
<option v-for="s in groups" :value='s.join(", ")'>
{{ s.join(", ") }}
</option>
</select>
</div>
</div>
</div>
<div class="col-sm">
<div class="d-flex flex-column gap-2 h-100">
<div class="d-flex align-items-center">
<label class="text-muted">
<small><LocaleText t="Listen Port Range"></LocaleText></small>
</label>
<p class="mb-0 ms-auto" v-if="!edit">
<small>
{{ template.ListenPortStart }}<i class="bi bi-arrow-right mx-2"></i>
{{ template.ListenPortEnd }}
</small>
</p>
<div v-else class="d-flex ms-auto align-items-center">
<input class="form-control-sm form-control rounded-3 ms-auto"
style="width: 80px"
v-model="data.ListenPortStart"
type="number"
>
<i class="bi bi-arrow-right mx-2"></i>
<input class="form-control-sm form-control rounded-3 ms-auto"
style="width: 80px"
v-model="data.ListenPortEnd"
type="number"
>
</div>
</div>
<div class="d-flex gap-2 flex-column mt-auto" v-if="!edit">
<label class="text-muted d-flex align-items-center gap-1" style="white-space: nowrap">
<small><LocaleText t="Available Ports"></LocaleText></small>
<span class="badge rounded-pill text-bg-success ms-auto">
{{ ports.length }}
</span>
</label>
<select
v-model="selectedPort"
class="form-select form-select-sm rounded-3 w-100 ms-auto">
<option :value="undefined" disabled>
<LocaleText t="Select..."></LocaleText>
</option>
<option v-for="p in [...ports]" :value='p'>
{{ p }}
</option>
</select>
</div>
</div>
</div>
</div>
<hr>
<div class="d-flex gap-2" v-if="!edit">
<button
type="button"
@click="edit = true; data = {...props.template}"
class="ms-auto btn btn-sm border-primary-subtle bg-primary-subtle text-primary-emphasis rounded-3">
<LocaleText t="Edit"></LocaleText>
</button>
<button
type="button"
@click="deleteTemplate()"
class="btn btn-sm border-danger-subtle bg-danger-subtle text-danger-emphasis rounded-3">
<LocaleText t="Delete"></LocaleText>
</button>
</div>
<div class="d-flex gap-2" v-else>
<button
type="button"
@click="isNew ? emits('remove') : edit = false"
class="ms-auto btn btn-sm border-secondary-subtle bg-secondary-subtle text-secondary-emphasis rounded-3">
<LocaleText t="Cancel"></LocaleText>
</button>
<button
type="button"
@click="saveTemplate()"
:class="{disabled: !readyToSave}"
class="btn btn-sm border-primary-subtle bg-primary-subtle text-primary-emphasis rounded-3">
<LocaleText t="Save"></LocaleText>
</button>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import LocaleText from "@/components/text/localeText.vue";
import {computed, ref} from "vue";
import {fetchGet} from "@/utilities/fetch.js"
import NewConfigurationTemplate from "@/components/newConfigurationComponents/newConfigurationTemplate.vue";
const emits = defineEmits(['subnet', 'port'])
const templates = ref([])
const getTemplates = async () => {
await fetchGet('/api/newConfigurationTemplates', {}, (res) => {
templates.value = res.data
})
}
await getTemplates()
const newTemplates = ref([])
const newTemplate = async () => {
await fetchGet('/api/newConfigurationTemplates/createTemplate', {}, (res) => {
newTemplates.value.push(res.data)
})
}
const numberOfIP = ref(256)
const calculateIP = ref(256)
</script>
<template>
<div class="card">
<div class="card-header">
<div class="d-flex align-items-center">
<LocaleText t="Templates"></LocaleText>
<button
type="button"
@click="newTemplate()"
class="btn btn-sm bg-success-subtle text-success-emphasis border-success-subtle rounded-3 ms-auto">
<i class="bi bi-plus-circle me-2"></i><LocaleText t="Add Template"></LocaleText>
</button>
</div>
<small class="text-muted">
<LocaleText t="Create templates to keep track a list of available Subnets & Listen Ports"></LocaleText>
</small>
</div>
<div class="card-body">
<div class="d-flex gap-2 align-items-center mb-2" v-if="templates.length > 0">
<label class="text-muted" style="white-space: nowrap">
<small><LocaleText t="No. of IP Address / Subnet"></LocaleText></small>
</label>
<input type="number"
v-model="numberOfIP"
@change="calculateIP = numberOfIP"
class="form-control form-control-sm rounded-3 w-100 ms-auto">
</div>
<div class="row g-2">
<div class="col-12" v-if="newTemplates.length === 0 && templates.length === 0">
<p class="text-center text-muted m-0">
<LocaleText t="No Templates"></LocaleText>
</p>
</div>
<div class="col-12" v-for="template in newTemplates">
<NewConfigurationTemplate
:edit="true"
:isNew="true"
@remove="newTemplates = newTemplates.filter(x => x.TemplateID !== template.TemplateID)"
@update="newTemplates = newTemplates.filter(x => x.TemplateID !== template.TemplateID); getTemplates()"
@subnet="args => emits('subnet', args)"
@port="args => emits('port', args)"
:template="template"></NewConfigurationTemplate>
</div>
<div class="col-12" v-for="(template, index) in templates">
<NewConfigurationTemplate
:key="template.TemplateID"
:peersCount="calculateIP"
@remove="getTemplates()"
@update="getTemplates()"
@subnet="args => emits('subnet', args)"
@port="args => emits('port', args)"
:template="template"></NewConfigurationTemplate>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -1,5 +1,5 @@
<script> <script>
import {parseCidr} from "cidr-tools"; import {parseCidr, containsCidr, mergeCidr, expandCidr} from "cidr-tools";
import '@/utilities/wireguard.js' import '@/utilities/wireguard.js'
import {WireguardConfigurationsStore} from "@/stores/WireguardConfigurationsStore.js"; import {WireguardConfigurationsStore} from "@/stores/WireguardConfigurationsStore.js";
import {fetchGet, fetchPost} from "@/utilities/fetch.js"; import {fetchGet, fetchPost} from "@/utilities/fetch.js";
@@ -7,10 +7,12 @@ import LocaleText from "@/components/text/localeText.vue";
import {parseInterface, parsePeers} from "@/utilities/parseConfigurationFile.js"; import {parseInterface, parsePeers} from "@/utilities/parseConfigurationFile.js";
import {ref} from "vue"; import {ref} from "vue";
import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js"; import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js";
import {exp} from "qrcode/lib/core/galois-field.js";
import NewConfigurationTemplates from "@/components/newConfigurationComponents/newConfigurationTemplates.vue";
export default { export default {
name: "newConfiguration", name: "newConfiguration",
components: {LocaleText}, components: {NewConfigurationTemplates, LocaleText},
async setup(){ async setup(){
const store = WireguardConfigurationsStore() const store = WireguardConfigurationsStore()
const protocols = ref([]) const protocols = ref([])
@@ -137,49 +139,57 @@ export default {
watch: { watch: {
'newConfiguration.Address'(newVal){ 'newConfiguration.Address'(newVal){
let ele = document.querySelector("#Address"); let ele = document.querySelector("#Address");
ele.classList.remove("is-invalid", "is-valid") if (ele){
try{ ele.classList.remove("is-invalid", "is-valid")
if (newVal.trim().split("/").filter(x => x.length > 0).length !== 2){ try{
throw Error() this.numberOfAvailableIPs = 0
newVal.replace(" ", "").split(",").forEach(x => {
let p = parseCidr(x);
let i = Number(p.end - p.start);
this.numberOfAvailableIPs += i + 1;
})
ele.classList.add("is-valid")
}catch (e) {
console.log(e)
this.numberOfAvailableIPs = "0";
ele.classList.add("is-invalid")
} }
let p = parseCidr(newVal);
let i = p.end - p.start;
this.numberOfAvailableIPs = i.toLocaleString();
ele.classList.add("is-valid")
}catch (e) {
this.numberOfAvailableIPs = "0";
ele.classList.add("is-invalid")
} }
}, },
'newConfiguration.ListenPort'(newVal){ 'newConfiguration.ListenPort'(newVal){
let ele = document.querySelector("#ListenPort"); let ele = document.querySelector("#ListenPort");
ele.classList.remove("is-invalid", "is-valid") if (ele){
ele.classList.remove("is-invalid", "is-valid")
if (newVal < 0 || newVal > 65353 || !Number.isInteger(newVal)){
ele.classList.add("is-invalid") if (newVal < 0 || newVal > 65353 || !Number.isInteger(newVal)){
}else{ ele.classList.add("is-invalid")
ele.classList.add("is-valid") }else{
ele.classList.add("is-valid")
}
} }
}, },
'newConfiguration.ConfigurationName'(newVal){ 'newConfiguration.ConfigurationName'(newVal){
let ele = document.querySelector("#ConfigurationName"); let ele = document.querySelector("#ConfigurationName");
ele.classList.remove("is-invalid", "is-valid") if (ele){
if (!/^[a-zA-Z0-9_=+.-]{1,15}$/.test(newVal) || newVal.length === 0 || this.store.Configurations.find(x => x.Name === newVal)){ ele.classList.remove("is-invalid", "is-valid")
ele.classList.add("is-invalid") if (!/^[a-zA-Z0-9_=+.-]{1,15}$/.test(newVal) || newVal.length === 0 || this.store.Configurations.find(x => x.Name === newVal)){
}else{ ele.classList.add("is-invalid")
ele.classList.add("is-valid") }else{
ele.classList.add("is-valid")
}
} }
}, },
'newConfiguration.PrivateKey'(newVal){ 'newConfiguration.PrivateKey'(newVal){
let ele = document.querySelector("#PrivateKey"); let ele = document.querySelector("#PrivateKey");
ele.classList.remove("is-invalid", "is-valid") if (ele){
ele.classList.remove("is-invalid", "is-valid")
try{
wireguard.generatePublicKey(newVal) try{
ele.classList.add("is-valid") wireguard.generatePublicKey(newVal)
}catch (e) { ele.classList.add("is-valid")
ele.classList.add("is-invalid") }catch (e) {
ele.classList.add("is-invalid")
}
} }
} }
}, },
@@ -304,6 +314,10 @@ export default {
</div> </div>
</div> </div>
</div> </div>
<NewConfigurationTemplates
@subnet="args => this.newConfiguration.Address = args"
@port="args => this.newConfiguration.ListenPort = args"
></NewConfigurationTemplates>
<div class="card rounded-3 shadow"> <div class="card rounded-3 shadow">
<div class="card-header"> <div class="card-header">
<LocaleText t="Listen Port"></LocaleText> <LocaleText t="Listen Port"></LocaleText>