main
vue 745 lines 20.5 KB
Raw
1 <template>
2 <n-spin :show="loading" class="customer-provision-wizard">
3 <div class="flex min-h-120 flex-col gap-4">
4 <div class="flex grow flex-col gap-6">
5 <n-scrollbar x-scrollable trigger="none">
6 <div class="p-1 pr-4">
7 <n-steps :current size="small" :status="currentStatus">
8 <n-step title="Provisioning" />
9 <n-step title="Graylog" />
10 <n-step title="Subscription" />
11 <n-step title="Infrastructure">
12 <template #icon>
13 <Icon v-if="!isInfrastructureEnabled" :name="SkipIcon" />
14 </template>
15 </n-step>
16 <n-step title="Wazuh Worker" :status="!isWazuhStepEnabled ? 'wait' : undefined">
17 <template #icon>
18 <Icon v-if="!isWazuhStepEnabled" :name="SkipIcon" />
19 </template>
20 </n-step>
21 </n-steps>
22 </div>
23 </n-scrollbar>
24
25 <n-form ref="formRef" :label-width="80" :model="form" :rules class="form-container">
26 <Transition :name="`slide-form-${slideFormDirection}`">
27 <div v-if="current === 1" class="flex flex-col gap-3">
28 <div class="flex flex-wrap gap-3">
29 <n-form-item label="Customer Code" path="customer_code" class="grow">
30 <n-input
31 v-model:value.trim="form.customer_code"
32 placeholder="Input Customer Code..."
33 readonly
34 disabled
35 />
36 </n-form-item>
37 <n-form-item label="Customer Name" path="customer_name" class="grow">
38 <n-input
39 v-model:value.trim="form.customer_name"
40 placeholder="Input Customer Name..."
41 readonly
42 disabled
43 />
44 </n-form-item>
45 </div>
46 <n-form-item
47 label="Customer Grafana.org Name"
48 path="customer_grafana_org_name"
49 class="grow"
50 >
51 <n-input
52 v-model:value.trim="form.customer_grafana_org_name"
53 placeholder="Customer Grafana.org Name..."
54 clearable
55 />
56 </n-form-item>
57 <n-form-item
58 v-if="showDfirIrisUsername"
59 label="DFIR-IRIS Username"
60 path="dfir_iris_username"
61 class="grow"
62 >
63 <n-input
64 v-model:value.trim="form.dfir_iris_username"
65 placeholder="The Username of the API Key CoPilot uses to connect to DFIR-IRIS..."
66 clearable
67 />
68 </n-form-item>
69 </div>
70
71 <div v-else-if="current === 2" class="flex flex-col gap-3">
72 <div class="flex flex-col gap-3 sm:flex-row">
73 <n-form-item
74 label="Customer Index name"
75 path="customer_index_name"
76 class="grow basis-1/2"
77 >
78 <n-input
79 v-model:value.trim="form.customer_index_name"
80 placeholder="Customer Index name..."
81 clearable
82 class="grow"
83 />
84 </n-form-item>
85
86 <n-form-item label="Index replicas" path="index_replicas" class="grow basis-1/2">
87 <n-input-number v-model:value="form.index_replicas" min="0" class="grow" />
88 </n-form-item>
89 </div>
90 <div class="flex flex-wrap gap-3">
91 <n-form-item label="Index shards" path="index_shards" class="grow">
92 <n-input-number v-model:value="form.index_shards" min="0" class="grow" />
93 </n-form-item>
94 <n-form-item label="Hot Data Retention (days)" path="hot_data_retention" class="grow">
95 <n-input-number v-model:value="form.hot_data_retention" min="0" class="grow" />
96 </n-form-item>
97 </div>
98 </div>
99
100 <div v-else-if="current === 3" class="flex flex-col gap-3">
101 <n-form-item label="Subscriptions" path="customer_subscription" class="grow">
102 <n-select
103 v-model:value="form.customer_subscription"
104 :options="subscriptionOptions"
105 :loading="loadingSubscriptions"
106 :placeholder="
107 loadingSubscriptions ? 'Loading Subscriptions...' : 'Select Subscriptions'
108 "
109 multiple
110 clearable
111 to="body"
112 class="grow"
113 />
114 </n-form-item>
115 <n-form-item
116 path="dashboards_to_include.dashboards"
117 class="grow"
118 label-style="flex-direction: column; align-items: stretch;"
119 :show-require-mark="false"
120 >
121 <template #label>
122 <div class="flex w-full items-center justify-between gap-4">
123 <div>
124 Dashboards
125 <span class="n-form-item-label__asterisk">*</span>
126 </div>
127 <div>
128 <n-button
129 v-if="dashboardOptions.length"
130 size="tiny"
131 @click="toggleDashboards()"
132 >
133 {{ allDashboardsSelected ? "Deselect All" : "Select All" }}
134 </n-button>
135 </div>
136 </div>
137 </template>
138 <n-select
139 v-model:value="form.dashboards_to_include.dashboards"
140 :options="dashboardOptions"
141 :loading="loadingDashboards"
142 :placeholder="loadingDashboards ? 'Loading Dashboards...' : 'Select Dashboards'"
143 multiple
144 clearable
145 to="body"
146 class="grow"
147 />
148 </n-form-item>
149 </div>
150
151 <div v-else-if="current === 4" class="flex gap-3">
152 <n-card class="grow">
153 <n-form-item label="Deploy Wazuh Worker" path="provision_wazuh_worker">
154 <n-switch v-model:value="form.provision_wazuh_worker" clearable />
155 </n-form-item>
156 <div class="text-secondary mt-2 text-sm">
157 Multi-tenancy (SOCFortress Pro Services only): Deploys a dedicated worker used in
158 SOCFortress-managed multi-tenant Wazuh environments. Leave off for single-tenant
159 deployments.
160 </div>
161 </n-card>
162 <n-card class="grow">
163 <n-form-item label="Deploy HA Proxy" path="provision_ha_proxy">
164 <n-switch
165 v-model:value="form.provision_ha_proxy"
166 clearable
167 :disabled="!form.provision_wazuh_worker"
168 />
169 </n-form-item>
170 <div class="text-secondary mt-2 text-sm">
171 Multi-tenancy (SOCFortress Pro Services only): Deploys the load balancer used to
172 route agent connections across multi-tenant Wazuh workers for high availability.
173 Leave off for single-tenant deployments.
174 </div>
175 </n-card>
176 </div>
177
178 <div v-else-if="current === 5" class="flex flex-col gap-3">
179 <div class="flex flex-wrap gap-3">
180 <n-form-item label="Auth Password" path="wazuh_auth_password" class="grow">
181 <n-input
182 v-model:value="form.wazuh_auth_password"
183 placeholder="Auth Password..."
184 clearable
185 />
186 </n-form-item>
187 <n-form-item label="Registration Port" path="wazuh_registration_port" class="grow">
188 <n-input
189 v-model:value="form.wazuh_registration_port"
190 placeholder="Registration Port..."
191 clearable
192 @blur="validate()"
193 />
194 </n-form-item>
195 <n-form-item label="Logs Port" path="wazuh_logs_port" class="grow">
196 <n-input
197 v-model:value="form.wazuh_logs_port"
198 placeholder="Logs Port..."
199 clearable
200 @blur="validate()"
201 />
202 </n-form-item>
203 <n-form-item label="Api Port" path="wazuh_api_port" class="grow">
204 <n-input
205 v-model:value="form.wazuh_api_port"
206 placeholder="Api Port..."
207 clearable
208 @blur="validate()"
209 />
210 </n-form-item>
211 <n-form-item label="Cluster Name" path="wazuh_cluster_name" class="grow">
212 <n-input
213 v-model:value="form.wazuh_cluster_name"
214 placeholder="Cluster Name..."
215 clearable
216 />
217 </n-form-item>
218 <n-form-item label="Cluster Key" path="wazuh_cluster_key" class="grow">
219 <n-input
220 v-model:value="form.wazuh_cluster_key"
221 placeholder="Cluster Key..."
222 clearable
223 />
224 </n-form-item>
225 <n-form-item label="Master IP" path="wazuh_master_ip" class="grow">
226 <n-input
227 v-model:value="form.wazuh_master_ip"
228 placeholder="Master IP..."
229 clearable
230 />
231 </n-form-item>
232 <n-form-item label="Grafana Url" path="grafana_url" class="grow">
233 <n-input v-model:value="form.grafana_url" placeholder="Grafana Url..." clearable />
234 </n-form-item>
235 </div>
236 <n-form-item
237 v-if="form.provision_ha_proxy"
238 label="Worker Hostname"
239 path="wazuh_worker_hostname"
240 class="grow"
241 >
242 <n-input
243 v-model:value="form.wazuh_worker_hostname"
244 placeholder="Worker Hostname..."
245 clearable
246 />
247 </n-form-item>
248 <div>* Registration Port, Logs Port, and Api Port must all be unique.</div>
249 </div>
250 </Transition>
251 </n-form>
252 </div>
253
254 <div class="flex justify-between gap-4">
255 <div class="flex gap-4">
256 <slot name="additionalActions"></slot>
257 </div>
258 <div class="flex gap-4">
259 <n-button v-if="isPrevStepEnabled" @click="prev()">
260 <template #icon>
261 <Icon :name="ArrowLeftIcon" />
262 </template>
263 Prev
264 </n-button>
265 <n-button v-if="isNextStepEnabled" icon-placement="right" @click="next()">
266 <template #icon>
267 <Icon :name="ArrowRightIcon" />
268 </template>
269 Next
270 </n-button>
271 <n-button v-if="isSubmitEnabled" type="primary" :loading @click="validate(submit)">Submit</n-button>
272 </div>
273 </div>
274 </div>
275 </n-spin>
276 </template>
277
278 <script setup lang="ts">
279 // TODO-FE: refactor
280 import type { FormInst, FormItemRule, FormRules, FormValidationError, StepsProps } from "naive-ui"
281 import type { CustomerMeta, CustomerProvision, CustomerProvisioningDefaultSettings } from "@/types/customers.d"
282 import _uniqBy from "lodash/uniqBy"
283 import {
284 NButton,
285 NCard,
286 NForm,
287 NFormItem,
288 NInput,
289 NInputNumber,
290 NScrollbar,
291 NSelect,
292 NSpin,
293 NStep,
294 NSteps,
295 NSwitch,
296 useMessage
297 } from "naive-ui"
298 import isIP from "validator/es/lib/isIP"
299 import isPort from "validator/es/lib/isPort"
300 import isURL from "validator/es/lib/isURL"
301 import { computed, onBeforeMount, ref, toRefs, watch } from "vue"
302 import Api from "@/api"
303 import Icon from "@/components/common/Icon.vue"
304
305 const props = defineProps<{
306 customerCode: string
307 customerName: string
308 mode?: "new" | "update"
309 showDfirIrisUsername?: boolean
310 }>()
311
312 const emit = defineEmits<{
313 (e: "update:loading", value: boolean): void
314 (e: "submitted", value: CustomerMeta): void
315 }>()
316
317 const { customerCode, customerName, mode, showDfirIrisUsername } = toRefs(props)
318
319 const SkipIcon = "carbon:subtract"
320 const ArrowRightIcon = "carbon:arrow-right"
321 const ArrowLeftIcon = "carbon:arrow-left"
322
323 const loading = ref(false)
324 const loadingSubscriptions = ref(false)
325 const loadingDashboards = ref(false)
326 const loadingDefaultSettings = ref(false)
327 const message = useMessage()
328 const current = ref<number>(1)
329 const currentStatus = ref<StepsProps["status"]>("process")
330 const form = ref<CustomerProvision>(getClearForm())
331 const formRef = ref<FormInst | null>(null)
332
333 const subscriptionOptions = ref<{ label: string; value: string }[]>([])
334 const dashboardOptions = ref<{ label: string; value: string }[]>([])
335
336 const allDashboardsSelected = computed(
337 () => form.value.dashboards_to_include.dashboards.length === dashboardOptions.value.length
338 )
339
340 const isInfrastructureEnabled = computed(() =>
341 form.value.customer_subscription.map(o => o.toLowerCase()).includes("wazuh")
342 )
343 const isWazuhStepEnabled = computed(() => isInfrastructureEnabled.value && form.value.provision_wazuh_worker)
344 const isNextStepEnabled = computed(
345 () =>
346 current.value < 3 ||
347 (current.value === 3 && isInfrastructureEnabled.value) ||
348 (current.value === 4 && isWazuhStepEnabled.value)
349 )
350 const isPrevStepEnabled = computed(() => current.value > 1)
351 const isSubmitEnabled = computed(
352 () =>
353 (current.value === 3 && !isInfrastructureEnabled.value) ||
354 (current.value === 4 && !isWazuhStepEnabled.value) ||
355 (current.value === 5 && isWazuhStepEnabled.value)
356 )
357 const slideFormDirection = ref<"right" | "left">("right")
358
359 const rules: FormRules = {
360 customer_name: {
361 required: true,
362 message: "Please input Customer Name",
363 trigger: ["input", "blur"]
364 },
365 customer_code: {
366 required: true,
367 message: "Please input Customer Code",
368 trigger: ["input", "blur"]
369 },
370 customer_grafana_org_name: {
371 required: true,
372 message: "Please input Customer Grafana.org Name",
373 trigger: ["input", "blur"]
374 },
375 customer_index_name: {
376 required: true,
377 message: "Please input Customer Index Name",
378 trigger: ["input", "blur"]
379 },
380 customer_subscription: {
381 required: true,
382 validator: validateAtLeastOne,
383 trigger: ["blur"]
384 },
385 dashboards_to_include: {
386 dashboards: {
387 required: true,
388 validator: validateAtLeastOne,
389 trigger: ["blur"]
390 }
391 },
392 wazuh_auth_password: {
393 required: true,
394 message: "Please input Auth password",
395 trigger: ["input", "blur"]
396 },
397 wazuh_registration_port: {
398 required: true,
399 validator: validatePort,
400 trigger: ["blur"]
401 },
402 wazuh_logs_port: {
403 required: true,
404 validator: validatePort,
405 trigger: ["blur"]
406 },
407 wazuh_api_port: {
408 required: true,
409 validator: validatePort,
410 trigger: ["blur"]
411 },
412 wazuh_cluster_name: {
413 required: true,
414 message: "Please input Cluster name",
415 trigger: ["input", "blur"]
416 },
417 wazuh_cluster_key: {
418 required: true,
419 message: "Please input Cluster key",
420 trigger: ["input", "blur"]
421 },
422 wazuh_master_ip: {
423 required: true,
424 validator: validateIp,
425 trigger: ["blur"]
426 },
427 grafana_url: {
428 required: true,
429 validator: validateUrl,
430 trigger: ["blur"]
431 },
432 wazuh_worker_hostname: {
433 required: true,
434 message: "Please input the Wazuh Worker Hostname",
435 trigger: ["input", "blur"]
436 }
437 }
438
439 function validateUrl(_rule: FormItemRule, value: string) {
440 if (!value || !isURL(value, { require_tld: false })) {
441 return new Error("Please input a valid URL")
442 }
443
444 return true
445 }
446
447 function validatePort(_rule: FormItemRule, value: string) {
448 if (!value || !isPort(value)) {
449 return new Error("Please input a valid Port number")
450 }
451
452 const array: string[] = [form.value.wazuh_registration_port, form.value.wazuh_logs_port, form.value.wazuh_api_port]
453 const filled: string[] = array.filter(value => !!value)
454 const uniques: string[] = array.filter((value, index, self) => self.indexOf(value) === index)
455
456 if (filled.length === array.length && uniques.length !== array.length) {
457 return new Error("Wazuh Ports must all be unique")
458 }
459
460 return true
461 }
462
463 function validateIp(_rule: FormItemRule, value: string) {
464 if (!value || !isIP(value)) {
465 return new Error("Please input a valid IP Address")
466 }
467
468 return true
469 }
470
471 function validateAtLeastOne(_rule: FormItemRule, value: string[]) {
472 if (!value || !value.length) {
473 return new Error("Please select at least one option")
474 }
475
476 return true
477 }
478
479 function getClearForm(settings?: CustomerProvisioningDefaultSettings): CustomerProvision {
480 return {
481 // step1
482 customer_code: customerCode.value,
483 customer_name: customerName.value,
484 customer_grafana_org_name: customerName.value,
485 dfir_iris_username: "",
486
487 // step 2
488 customer_index_name: "",
489 index_replicas: 0,
490 index_shards: 1,
491 hot_data_retention: 0,
492
493 // step 3
494 customer_subscription: ["Wazuh"],
495 dashboards_to_include: {
496 dashboards: [],
497 organizationId: 0, // hide on form
498 folderId: 0, // hide on form
499 datasourceUid: "uid-to-be-replaced" // hide on form
500 },
501
502 // step 4
503 provision_ha_proxy: false,
504 provision_wazuh_worker: false,
505
506 // step 5
507 wazuh_auth_password: "",
508 wazuh_registration_port: "",
509 wazuh_logs_port: "",
510 wazuh_api_port: "",
511 wazuh_cluster_name: settings?.cluster_name || "",
512 wazuh_cluster_key: settings?.cluster_key || "",
513 wazuh_master_ip: settings?.master_ip || "",
514 grafana_url: settings?.grafana_url || "",
515 wazuh_worker_hostname: settings?.wazuh_worker_hostname || ""
516 }
517 }
518
519 function next() {
520 validate(() => {
521 currentStatus.value = "process"
522 slideFormDirection.value = "right"
523 current.value++
524 })
525 }
526
527 function prev() {
528 currentStatus.value = "process"
529 slideFormDirection.value = "left"
530 current.value--
531 }
532
533 function getProvisioningDefaultSettings() {
534 loadingDefaultSettings.value = true
535
536 Api.customers
537 .getProvisioningDefaultSettings()
538 .then(res => {
539 if (res.data.success) {
540 setForm(res.data?.customer_provisioning_default_settings)
541 }
542 })
543 .finally(() => {
544 loadingDefaultSettings.value = false
545 })
546 }
547
548 function getSubscriptions() {
549 loadingSubscriptions.value = true
550
551 Api.customers
552 .getProvisioningSubscriptions()
553 .then(res => {
554 if (res.data.success) {
555 subscriptionOptions.value = _uniqBy(
556 (res.data?.available_subscriptions || []).map(o => ({ label: o, value: o })),
557 "value"
558 )
559 } else {
560 message.warning(res.data?.message || "An error occurred. Please try again later.")
561 }
562 })
563 .catch(err => {
564 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
565 })
566 .finally(() => {
567 loadingSubscriptions.value = false
568 })
569 }
570
571 function getDashboards() {
572 loadingDashboards.value = true
573
574 Api.customers
575 .getProvisioningDashboards()
576 .then(res => {
577 if (res.data.success) {
578 dashboardOptions.value = _uniqBy(
579 (res.data?.available_dashboards || []).map(o => ({ label: o, value: o })),
580 "value"
581 )
582 } else {
583 message.warning(res.data?.message || "An error occurred. Please try again later.")
584 }
585 })
586 .catch(err => {
587 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
588 })
589 .finally(() => {
590 loadingDashboards.value = false
591 })
592 }
593
594 function toggleDashboards() {
595 if (allDashboardsSelected.value) {
596 form.value.dashboards_to_include.dashboards = []
597 } else {
598 form.value.dashboards_to_include.dashboards = dashboardOptions.value
599 .filter(
600 o =>
601 !["EDR_WAZUH_INVENTORY", "EDR_AGENT_INVENTORY"]
602 .map(d => d.toLowerCase())
603 .includes(o.value.toLowerCase())
604 )
605 .map(o => o.value)
606 }
607 }
608
609 function validate(cb?: () => void) {
610 if (!formRef.value) return
611
612 formRef.value.validate((errors?: Array<FormValidationError>) => {
613 if (!errors) {
614 if (cb) cb()
615 } else {
616 message.warning("You must fill in the required fields correctly.")
617 return false
618 }
619 })
620 }
621
622 function reset() {
623 currentStatus.value = "process"
624 slideFormDirection.value = "right"
625 current.value = 1
626 setForm()
627 }
628
629 function setForm(settings?: CustomerProvisioningDefaultSettings) {
630 form.value = getClearForm(settings)
631 }
632
633 async function submit() {
634 currentStatus.value = "finish"
635 loading.value = true
636
637 if (mode.value === "update") {
638 await Api.customers.decommissionCustomer(customerCode.value)
639 }
640
641 Api.customers
642 .newCustomerProvision(form.value, customerCode.value)
643 .then(res => {
644 if (res.data.success) {
645 emit("submitted", res.data.customer_meta)
646 reset()
647 } else {
648 message.warning(res.data?.message || "An error occurred. Please try again later.")
649 }
650 })
651 .catch(err => {
652 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
653 })
654 .finally(() => {
655 loading.value = false
656 })
657 }
658
659 function generateRandomPassword(length: number = 20): string {
660 const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
661 const lowercase = "abcdefghijklmnopqrstuvwxyz"
662 const numbers = "0123456789"
663 const allChars = uppercase + lowercase + numbers
664
665 let password = ""
666 // Ensure at least one of each character type
667 password += uppercase[Math.floor(Math.random() * uppercase.length)]
668 password += lowercase[Math.floor(Math.random() * lowercase.length)]
669 password += numbers[Math.floor(Math.random() * numbers.length)]
670
671 // Fill the rest randomly
672 for (let i = password.length; i < length; i++) {
673 password += allChars[Math.floor(Math.random() * allChars.length)]
674 }
675
676 // Shuffle the password to randomize the guaranteed characters
677 return password
678 .split("")
679 .sort(() => Math.random() - 0.5)
680 .join("")
681 }
682
683 function formPreset(step: number) {
684 switch (step) {
685 case 2:
686 if (!form.value.customer_index_name) {
687 form.value.customer_index_name = `wazuh-${form.value.customer_code}`
688 }
689 break
690 case 5:
691 if (!form.value.wazuh_auth_password) {
692 form.value.wazuh_auth_password = generateRandomPassword()
693 }
694 break
695 }
696 }
697
698 watch(
699 () => form.value.provision_wazuh_worker,
700 val => {
701 if (!val) {
702 form.value.provision_ha_proxy = false
703 }
704 }
705 )
706
707 watch(current, val => {
708 formPreset(val)
709 })
710
711 onBeforeMount(() => {
712 getProvisioningDefaultSettings()
713 getSubscriptions()
714 getDashboards()
715 })
716 </script>
717
718 <style lang="scss" scoped>
719 .customer-provision-wizard {
720 .slide-form-right-enter-active,
721 .slide-form-right-leave-active,
722 .slide-form-left-enter-active,
723 .slide-form-left-leave-active {
724 transition: all 0.2s ease-out;
725 position: absolute;
726 width: 100%;
727 }
728
729 .slide-form-left-enter-from {
730 transform: translateX(-100%);
731 }
732
733 .slide-form-left-leave-to {
734 transform: translateX(100%);
735 }
736
737 .slide-form-right-enter-from {
738 transform: translateX(100%);
739 }
740
741 .slide-form-right-leave-to {
742 transform: translateX(-100%);
743 }
744 }
745 </style>