@@ 0,0 1,468 @@
+<template>
+ <o-modal
+ v-model:active="active"
+ aria-role="dialog"
+ aria-label="Downloading Configuration"
+ aria-modal
+ has-modal-card
+ trap-focus
+ >
+ <app-modal-form
+ :cancel="cancelOrCloseModal"
+ :cancel-label="cancelOrCloseLabel"
+ title="Downloading Configuration"
+ >
+ <div class="content">
+ <ul class="progress-details">
+ <li v-for="result in data" :key="result.id">
+ <span>{{ result.attr.name }}</span>
+ <ul>
+ <li
+ v-for="item in result.meta.log"
+ :key="item"
+ :class="`has-text-${item.variant || 'grey-slate'}`"
+ >
+ {{ item.message }}
+ </li>
+ </ul>
+ </li>
+ </ul>
+ <app-progress
+ :value="loadingProgress"
+ size="large"
+ variant="primary"
+ show-value
+ >
+ {{ done }} of {{ total }}
+ </app-progress>
+ </div>
+ </app-modal-form>
+ </o-modal>
+</template>
+
+<script setup>
+import { computed, inject, nextTick, ref, unref, watch } from 'vue'
+import { ZipWriter } from '@zip.js/zip.js'
+import { compareDates } from '@/utils/date'
+import {
+ download,
+ generateAgentConf,
+ generateAgentSetupConf,
+ generateWgQuickConf,
+} from '@/utils/download'
+import { env } from '@/utils/env'
+import { toast } from '@/utils/message'
+import { normalizeModel, normalizeModels, simulateModel } from '@/utils/model'
+import { getPageantryParams, emptyPageantryState } from '@/utils/pageantry'
+
+const props = defineProps({
+ /** True if open (default false). */
+ modelValue: { type: Boolean, default: false },
+ /** List of host names. */
+ hosts: { type: Array, default: () => [] },
+ /** Hub interface model. */
+ interfaceModel: { type: Object, default: normalizeModel },
+ /** Download type. */
+ downloadFiles: { type: String, default: 'agent' },
+ /** How to handle invalid characters in host names. */
+ invalidChars: { type: String, default: 'skip' },
+})
+const emit = defineEmits([
+ 'complete',
+ 'cancel',
+ 'progress',
+ 'update:modelValue',
+])
+
+const $axiosApi = inject('$axiosApi')
+
+/** Promise to generate zip file content. */
+let zipPromise = null
+/** Active ZipWriter object. */
+let zipWriter = null
+
+/**
+ * List of processed host models.
+ * Each host model has an added `log` meta attr for list of log messages.
+ */
+const data = ref([])
+/** List of endpoints paged thus far. */
+const endpoints = ref([])
+/** Total count of endpoints. */
+const endpointsTotal = ref(0)
+/** Paging for endpoints. */
+const endpointsPageantry = ref(emptyPageantryState())
+/** True if processing. */
+const loading = ref(false)
+
+/** True if dialog opened. */
+const active = computed({
+ get: () => props.modelValue,
+ set: (v, old) => (v !== old ? emit('update:modelValue', v) : undefined),
+})
+/** Label of close/cancel button. */
+const cancelOrCloseLabel = computed(() => (unref(loading) ? 'Cancel' : 'Close'))
+/** Progress percentage (0.0-1.0). */
+const loadingProgress = computed(() => {
+ const t = unref(total)
+ return t ? (unref(done) * 100) / t : 0
+})
+/** Count of processed host names. */
+const done = computed(() => data.value.length)
+/** Total host names to process. */
+const total = computed(() => props.hosts.length || unref(endpointsTotal))
+
+watch(active, (v) => (v ? load() : undefined))
+
+/**
+ * Cancels the processing; or if finished processing, closes the dialog.
+ */
+function cancelOrCloseModal() {
+ if (loading.value) {
+ loading.value = false
+ emit('cancel')
+ } else {
+ active.value = false
+ }
+}
+
+/**
+ * Starts the processing.
+ */
+async function load() {
+ loading.value = true
+ data.value = []
+ endpoints.value = []
+ endpointsTotal.value = 0
+ initZip()
+ await loadMoreEndpoints()
+ nextTick(loadNext)
+}
+
+/**
+ * Processes the next host name.
+ */
+async function loadNext() {
+ const currentTotal = unref(total)
+ const currentDone = unref(done)
+ if (!unref(loading) || !currentTotal) return
+ if (currentDone >= currentTotal) return downloadZip()
+
+ let host = null
+ let ifaces = null
+
+ if (props.hosts.length) {
+ host = await findHost(props.hosts[currentDone])
+ if (abortHost(host)) return nextHost(host)
+ } else {
+ const endpoint = await loadNextEndpoint()
+ host = modelHost(endpoint.rel('host'))
+ ifaces = [endpoint.rel('interface')]
+ if (abortHost(host)) return nextHost(host)
+ }
+
+ if (props.downloadFiles === 'wireguard') {
+ if (!ifaces) ifaces = await findInterfaces(host)
+ await Promise.all(ifaces.map((iface) => addToZip(host, iface)))
+ } else {
+ await addToZip(host)
+ }
+
+ nextHost(host)
+}
+
+/**
+ * Returns true if processing has been canceled or a warning message logged.
+ * @param host Host model to check for warning message.
+ * @return {boolean} True if aborting.
+ */
+function abortHost(host) {
+ return (
+ !unref(loading) ||
+ host.meta.log.some((x) => ['danger', 'warning'].includes(x.variant))
+ )
+}
+
+/**
+ * Adds the specified host model to the list of processed models,
+ * and queues the next set of work.
+ * @param host Host model.
+ */
+function nextHost(host) {
+ data.value.push(host)
+ emit('progress', data.value)
+ nextTick(loadNext)
+}
+
+/**
+ * Ensures the specified host has an appropriate model.
+ * @param host Host model.
+ * @param {string} name Host name if host is null.
+ * @return Annotated host model.
+ */
+function modelHost(host, name) {
+ host = host || simulateModel({ attr: { name } })
+ if (!host.meta.log) host.meta.log = []
+ return host
+}
+
+/**
+ * Addes a log message to the specified host model.
+ * @param host Host model.
+ * @param {string} message Message to log.
+ * @param {string} variant Log level (eg 'warning').
+ */
+function log(host, message, variant) {
+ host.meta.log.push({ message, variant })
+}
+
+/**
+ * Finds the specified host by name.
+ * @param {string} name Host name.
+ * @return Host model.
+ */
+async function findHost(name) {
+ if (props.invalidChars === 'skip' && name !== makeSafeName(name)) {
+ const host = modelHost(null, name)
+ log(host, 'Name contains invalid filename characters', 'warning')
+ log(host, 'Skipped', 'warning')
+ return host
+ }
+ const hosts = await findHosts(name)
+ const host = modelHost(hosts[0], name)
+ if (!unref(loading)) return host
+
+ if (hosts.length > 1) {
+ log(host, 'Found more than one host', 'warning')
+ log(host, 'Skipped', 'warning')
+ } else if (hosts.length < 1) {
+ log(host, 'Missing host', 'danger')
+ }
+
+ return host
+}
+
+/**
+ * Finds the list of hosts with the specified name.
+ * @param {string} name Host name.
+ * @return List of host models.
+ */
+async function findHosts(name) {
+ const { data } = await $axiosApi.get('/hosts', {
+ params: { q: `"${name}"`, field: 'name' },
+ })
+ return normalizeModels(data)
+}
+
+/**
+ * Finds all interfaces for the specified host.
+ * @param host Host model.
+ * @return List of interface models.
+ */
+async function findInterfaces(host) {
+ const { data } = await $axiosApi.get(`/hosts/${host.id}/interfaces/all`)
+ const ifaces = normalizeModels(data)
+ if (ifaces.length < 1) log(host, 'Missing interface', 'danger')
+ return ifaces
+}
+
+/**
+ * Loads the next endpoint for the selected interface model.
+ * @return Endpoint model.
+ */
+async function loadNextEndpoint() {
+ const currentDone = unref(done)
+ if (currentDone >= unref(endpoints).length) await loadMoreEndpoints()
+
+ const endpoint = unref(endpoints)[currentDone]
+ if (!endpoint) {
+ const host = modelHost(null, `Missing host #${currentDone + 1}`)
+ log(host, 'Missing host', 'danger')
+ return simulateModel({ rr: { host: [host] } })
+ }
+
+ const host = modelHost(endpoint.rel('host'))
+ const { name } = host.attr
+ if (props.invalidChars === 'skip' && name !== makeSafeName(name)) {
+ log(host, 'Name contains invalid filename characters', 'warning')
+ log(host, 'Skipped', 'warning')
+ return simulateModel({ attr: host.attr, rr: { host: [host] } })
+ }
+
+ return endpoint
+}
+
+/**
+ * Loads the next page of endpoints for the selected interface model.
+ */
+async function loadMoreEndpoints() {
+ const { id } = props.interfaceModel.rel('peer')
+ if (!id || props.hosts.length) return
+
+ endpointsPageantry.value.off = endpoints.value.length
+ const { data } = await $axiosApi.get(`/peers/${id}/endpoints`, {
+ params: getPageantryParams(endpointsPageantry.value),
+ })
+ endpointsTotal.value = data.meta.total
+ endpoints.value.push(...normalizeModels(data))
+}
+
+/**
+ * Replaces filename-unsafe characters with dashes.
+ * @param {string} name Filename.
+ * @return {string} Safe filename.
+ */
+function makeSafeName(name) {
+ // eslint-disable-next-line no-control-regex
+ return name ? name.replace(/[\x00–\x1f\x7f"*/:<>?\\|]+/g, '') : ''
+}
+
+/**
+ * Initializes the zip file structures.
+ */
+function initZip() {
+ if (zipWriter) zipWriter.close()
+ if (props.downloadFiles === 'nothing') return
+
+ const { readable, writable } = new TransformStream()
+ zipPromise = new Response(readable).blob()
+ zipWriter = new ZipWriter(writable)
+}
+
+/**
+ * Adds the configuration files for specified host and interface to the zip.
+ * @param host Host model.
+ * @param iface Interface model.
+ */
+async function addToZip(host, iface) {
+ if (props.downloadFiles === 'nothing') return
+
+ const directory = `hosts/${makeSafeName(host.attr.name)}`
+ let result = null
+
+ if (props.downloadFiles === 'wireguard') {
+ iface = await loadInterfacePrivateKey(iface.id)
+ result = await addFileToZip(
+ `${directory}/${iface.attr.name}.conf`,
+ generateWgQuickConf(iface),
+ )
+ } else {
+ const agent = await loadAgent(host.id)
+ result = await addFileToZip(
+ `${directory}/${env.confName}.conf`,
+ generateAgentConf(agent, host),
+ )
+ if (result.variant === 'success') {
+ const code = await loadCode(agent.id)
+ result = await addFileToZip(
+ `${directory}/${env.confName}-setup.conf`,
+ generateAgentSetupConf(agent, code),
+ )
+ }
+ }
+
+ log(host, result.message, result.variant)
+}
+
+/**
+ * Adds the specified content to the zip under the specified path.
+ * Returns a log entry indicating success or error.
+ * @param {string} path Path in zip (eg 'hosts/Foo Bar/agent.conf').
+ * @param {string} content File content.
+ * @return Log entry with `message` and `variant` properties.
+ */
+async function addFileToZip(path, content) {
+ try {
+ await zipWriter.add(path, new Blob([content]).stream())
+ return { message: 'Zipped', variant: 'success' }
+ } catch (e) {
+ console.error(e) // eslint-disable-line no-console
+ return { message: `Zip error: ${e}`, variant: 'danger' }
+ }
+}
+
+/**
+ * Completes the processing and downloads the zip file.
+ */
+async function downloadZip() {
+ if (zipWriter) {
+ await zipWriter.close()
+ zipWriter = null
+ const zip = await zipPromise
+ if (zip.size > 100) download(zip, `${env.confName}-hosts.zip`, 'blob')
+ }
+
+ const message = buildDoneMessage()
+ toast(message, { variant: / 0 /.test(message) ? 'warning' : 'success' })
+ loading.value = false
+ emit('complete')
+}
+
+/**
+ * Retreives the full key material for the specified interface, as well as its
+ * enpoints (from which to generate the full wg-quick conf).
+ * @param {string} interfaceId Interface pub ID.
+ * @return Model for interface.
+ */
+async function loadInterfacePrivateKey(interfaceId) {
+ const url = `/interfaces/${interfaceId}/expected-conf-with-private-key`
+ const { data } = await $axiosApi.get(url)
+ return normalizeModel(data)
+}
+
+/**
+ * Retrieves the active agent from the api.
+ * @param {string} hostId Host pub ID.
+ * @return Model for agent user.
+ */
+async function loadAgent(hostId) {
+ const { data } = await $axiosApi.get(`/hosts/${hostId}/members`)
+ const agent = normalizeModels(data)
+ .concat()
+ .sort((a, b) => compareDates(a, b, (x) => x.attr.created))
+ .find((x) => ['logger', 'supervisor'].includes(x.attr.membership))
+ if (agent) return agent
+
+ const rs = await $axiosApi.post(`/hosts/${hostId}/members/agent`)
+ return normalizeModel(rs.data)
+}
+
+/**
+ * Retrieves the active setup code from the api.
+ * @param {string} agentId Agent pub ID.
+ * @return Model for agent_setup user-code.
+ */
+async function loadCode(agentId) {
+ const url = `/users/${agentId}/credentials/signature/setup`
+ const { data } = await $axiosApi.post(url)
+ return normalizeModel(data)
+}
+
+/**
+ * Builds the completion message.
+ * @return {string} Completion message.
+ */
+function buildDoneMessage() {
+ const zipped = data.value.filter((host) => {
+ return host.meta.log.some((x) => x.message === 'Zipped')
+ })
+ return `Zipped ${zipped.length} hosts`
+}
+</script>
+
+<style scoped lang="scss">
+.progress-details {
+ margin: 0;
+ height: 10rem;
+ overflow: scroll;
+ list-style: disc inside;
+
+ ul {
+ margin: 0 1rem 0.5rem;
+
+ li {
+ margin: 0 1rem;
+ }
+ }
+}
+</style>
@@ 0,0 1,216 @@
+<template>
+ <app-panel label="Bulk Download" :loading="loading">
+ <app-form
+ :input-refs="inputRefs"
+ :cancel="cancel"
+ :submitting="submitting"
+ :submit="submit"
+ submit-label="Download"
+ >
+ <o-field label="From" class="radio-stack">
+ <o-radio v-model="source" native-value="csv">
+ List of host names
+ </o-radio>
+ </o-field>
+ <o-field>
+ <o-radio v-model="source" native-value="connections">
+ All connections to a hub host
+ </o-radio>
+ </o-field>
+ <o-field
+ v-if="source === 'csv'"
+ message="List of hosts to download (one host per line)."
+ label="Hosts to Download"
+ >
+ <o-input
+ ref="csvInput"
+ v-model="csv"
+ :placeholder="csvPlaceholder"
+ type="textarea"
+ validation-message="required"
+ required
+ autofocus
+ />
+ </o-field>
+ <template v-if="source === 'connections'">
+ <o-field v-if="hostModel.id" label="Hub Host">
+ <span>{{ hostModel.attr.name }}</span>
+ </o-field>
+ <host-pick-field
+ v-else
+ ref="hostInput"
+ v-model="hostId"
+ label="Hub Host"
+ required
+ />
+ <o-field v-if="interfaceModel.id" label="Hub Interface">
+ <span>{{ interfaceModel.attr.name }}</span>
+ </o-field>
+ <interface-pick-field
+ v-else
+ ref="interfaceInput"
+ v-model="interfaceId"
+ :host-id="hostId"
+ label="Hub Interface"
+ required
+ />
+ </template>
+ <o-field label="Download" class="radio-stack">
+ <o-radio v-model="downloadFiles" native-value="agent">
+ Agent config files
+ </o-radio>
+ </o-field>
+ <o-field class="radio-stack">
+ <o-radio v-model="downloadFiles" native-value="wireguard">
+ WireGuard config files
+ </o-radio>
+ </o-field>
+ <o-field label="Invalid Names" class="radio-stack">
+ <o-radio v-model="invalidChars" native-value="skip">
+ Skip and warn
+ </o-radio>
+ </o-field>
+ <o-field>
+ <o-radio v-model="invalidChars" native-value="remove">
+ Strip invalid filename characters from download
+ </o-radio>
+ </o-field>
+ </app-form>
+ <host-bulk-download-modal
+ v-model="submitting"
+ :hosts="hosts"
+ :interface-model="iface"
+ :download-files="downloadFiles"
+ :invalid-chars="invalidChars"
+ />
+ </app-panel>
+</template>
+
+<script setup>
+import { computed, inject, onMounted, ref, unref, watch } from 'vue'
+import { normalizeModel } from '@/utils/model'
+
+const props = defineProps({
+ /** True if model is loading. */
+ loading: { type: Boolean, default: false },
+ /** Host model. */
+ hostModel: { type: Object, default: normalizeModel },
+ /** Interface model. */
+ interfaceModel: { type: Object, default: normalizeModel },
+})
+const emit = defineEmits(['cancel'])
+
+const $axiosApi = inject('$axiosApi')
+
+/** Selected host ID. */
+const hostId = ref('')
+const interfaceId = ref('')
+/** Selected interface model. */
+const iface = ref(props.interfaceModel)
+
+/** Raw CSV input text. */
+const csv = ref('')
+/** 'Download' radio button value. */
+const downloadFiles = ref('agent')
+/** 'Invalid name' radio button value. */
+const invalidChars = ref('skip')
+/** 'Source' radio button value. */
+const source = ref('csv')
+/** True if submitting form. */
+const submitting = ref(false)
+
+/** Placeholder text for CSV input. */
+const csvPlaceholder = ref(
+ `
+Alice's Laptop
+Bob's Phone
+Carla's Workstation
+`.trim(),
+)
+
+const csvInput = ref()
+const hostInput = ref()
+const interfaceInput = ref()
+const inputRefs = computed(() => {
+ return unref(source) === 'csv' ? [csvInput] : [hostInput, interfaceInput]
+})
+
+/** Parsed list of host names. */
+const hosts = computed(() => {
+ if (unref(source) !== 'csv') return []
+ return unref(csv)
+ .split(/\r?\n/)
+ .map((x) => x.trim())
+ .filter((x) => x)
+})
+
+watch(hostId, selectDefaultInterface)
+watch(interfaceId, loadInterface)
+onMounted(selectDefaultHost)
+
+/**
+ * Selects the default hub host on load.
+ */
+async function selectDefaultHost() {
+ if (props.hostModel.id) return
+
+ const { data } = await $axiosApi.get('/hosts', {
+ params: { q: '"hub"', field: 'external' },
+ })
+ const host = normalizeModel(data)
+ if (host.id) hostId.value = host.id
+}
+
+/**
+ * Selects the default interface for the specified host.
+ * @param {string} id Host pub ID.
+ * @param {string} old Previous ID value.
+ */
+async function selectDefaultInterface(id, old) {
+ if (id === old) return
+ if (!id) {
+ interfaceId.value = ''
+ return
+ }
+ const { data } = await $axiosApi.get(`/hosts/${id}/interfaces/all`)
+ const iface = normalizeModel(data)
+ interfaceId.value = iface.id || ''
+}
+
+/**
+ * Loads the specified interface model.
+ * @param {string} id Interface pub ID.
+ * @param {string} old Previous ID value.
+ */
+async function loadInterface(id, old) {
+ if (id === old) return
+ if (!id) {
+ iface.value = normalizeModel()
+ return
+ }
+ const { data } = await $axiosApi.get(`/interfaces/${id}`, {
+ params: { included: 'peer' },
+ })
+ iface.value = normalizeModel(data)
+}
+
+/**
+ * Submits the upload form, starting the upload process.
+ */
+function submit() {
+ submitting.value = true
+}
+
+/**
+ * Cancels the upload form.
+ */
+function cancel() {
+ emit('cancel')
+}
+</script>
+
+<style scoped lang="scss">
+.preview-table {
+ padding-top: 1rem;
+}
+</style>