@@ 0,0 1,730 @@
+<template>
+ <o-modal
+ v-model:active="active"
+ aria-role="dialog"
+ aria-label="Adding Hosts by Bulk"
+ aria-modal
+ has-modal-card
+ trap-focus
+ >
+ <app-modal-form
+ :cancel="cancelOrCloseModal"
+ :cancel-label="cancelOrCloseLabel"
+ title="Adding Hosts by Bulk"
+ >
+ <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 {
+ generateNextAddress,
+ joinAddress,
+ parseAddress,
+ splitAddress,
+} from '@/utils/ip'
+import { toast } from '@/utils/message'
+import { normalizeModel, normalizeModels, simulateModel } from '@/utils/model'
+import { randomBase64 } from '@/utils/text'
+
+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' },
+ /** How to handle existing hosts. */
+ existingHosts: { type: String, default: 'skip' },
+})
+const emit = defineEmits([
+ 'complete',
+ 'cancel',
+ 'progress',
+ 'update:modelValue',
+])
+
+const $axiosApi = inject('$axiosApi')
+
+/**
+ * List of processed host models.
+ * Each host model has an added `log` meta attr for list of log messages.
+ */
+const data = ref([])
+/** Hub peer model. */
+const hubPeer = ref(null)
+/** True if processing. */
+const loading = ref(false)
+/** Last interface address selected from each of the hub's subnets. */
+const lastAddresses = ref([])
+/** List of interface IP addresses already used by organization. */
+const unavailableAddresses = ref([])
+
+/** 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)
+
+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 = []
+ await loadHub()
+ await loadAddresses()
+ nextTick(loadNext)
+}
+
+/**
+ * Loads the hub peer model.
+ */
+async function loadHub() {
+ const { id } = props.interfaceModel.rel('peer')
+ const { data } = await $axiosApi.get(`/peers/${id}`, {
+ params: { included: 'defaults' },
+ })
+ const model = normalizeModel(data)
+ hubPeer.value = model
+ const net = model.rel('peer_endpoint_routing_default').attr.subnet || []
+ lastAddresses.value = net.map(parseAddress)
+}
+
+/**
+ * Loads the unavailable addresses list.
+ */
+async function loadAddresses() {
+ const { data: addr } = await $axiosApi.get('/interfaces/addresses')
+ unavailableAddresses.value = addr.data.map((x) => parseAddress(x.id))
+}
+
+/**
+ * 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()
+
+ const name = unref(props.hosts)[currentDone]
+ const host = await findOrCreateHost(name)
+ if (abortHost(host)) return nextHost(host)
+
+ const iface = await findOrCreateInterface(host)
+ if (abortHost(host)) return nextHost(host)
+
+ const endpoint = await findOrCreateEndpoint(host, iface)
+ if (abortHost(host)) return nextHost(host)
+
+ await findOrCreateHubEndpoint(host, iface, endpoint)
+ if (abortHost(host)) return nextHost(host)
+
+ addToZip(host, iface)
+ 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 } })
+ host.meta = { log: [] }
+ return host
+}
+
+/**
+ * Ensures the specified model has a "new" flag.
+ * @param o Model.
+ * @return Annotated host model.
+ */
+function modelNu(o) {
+ o.nu = true
+ return o
+}
+
+/**
+ * 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 or creates the specified host by name.
+ * @param {string} name Host name.
+ * @return Host model.
+ */
+async function findOrCreateHost(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)
+ let 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) {
+ await findAndWarnAboutPeer(host)
+ if (abortHost(host)) return host
+
+ host = await createHost(name)
+ log(host, 'Created new host', 'success')
+ } else if (props.existingHosts === 'delete') {
+ await findAndWarnAboutPeer(host)
+ if (abortHost(host)) return host
+
+ await deleteHost(host)
+ log(host, 'Deleted existing host', 'success')
+ host = await createHost(name)
+ log(host, 'Created new host', 'success')
+ } else {
+ log(host, 'Found existing host')
+ }
+
+ return host
+}
+
+/**
+ * Finds the peer matching the specified host's name,
+ * and warns appropriately if found.
+ * @param host Host model.
+ * @return Peer model or null.
+ */
+async function findAndWarnAboutPeer(host) {
+ const peers = await findPeers(host.attr.name)
+ const peer = peers[0]
+ if (!unref(loading) || !peer) return peer
+
+ if (peers.length > 1) {
+ log(host, 'Found more than one peer', 'warning')
+ log(host, 'Skipped', 'warning')
+ } else if (props.existingHosts === 'skip') {
+ log(host, 'Found existing peer', 'warning')
+ log(host, 'Skipped', 'warning')
+ } else if (props.existingHosts === 'download' && !host.id) {
+ log(host, 'Found existing peer without host', 'warning')
+ log(host, 'Skipped', 'warning')
+ } else if (props.existingHosts !== 'delete') {
+ log(host, 'Found existing peer')
+ }
+
+ return peer
+}
+
+/**
+ * Finds or creates the peer matching the specified host's name.
+ * @param host Host model.
+ * @return Peer model or null.
+ */
+async function findOrCreatePeer(host) {
+ const peerName = host.attr.name
+ let peer = await findAndWarnAboutPeer(host)
+ if (abortHost(host)) return peer
+
+ if (peer && props.existingHosts === 'delete') {
+ await deletePeer(peer)
+ log(host, 'Deleted existing peer', 'success')
+ peer = null
+ }
+ if (!peer) {
+ peer = await createPeer(peerName)
+ log(host, 'Created new peer', 'success')
+ }
+
+ return peer
+}
+
+/**
+ * Finds or creates an interface for the specified host.
+ * @param host Host model.
+ * @return Interface model or null.
+ */
+async function findOrCreateInterface(host) {
+ let iface = await findInterface(host)
+ if (!unref(loading)) return iface
+
+ if (!iface) {
+ const peer = await findOrCreatePeer(host)
+ if (!peer || !unref(loading)) return null
+
+ iface = await createInterface(host, peer)
+ log(host, 'Created new interface', 'success')
+ } else if (props.existingHosts === 'skip') {
+ log(host, 'Found existing interface', 'warning')
+ log(host, 'Skipped', 'warning')
+ } else {
+ log(host, 'Found existing interface')
+ }
+
+ return iface
+}
+
+/**
+ * Finds or creates an endpoint for the specified host and interface.
+ * @param host Host model.
+ * @param iface Interface model.
+ * @return Endpoint model or null.
+ */
+async function findOrCreateEndpoint(host, iface) {
+ if (!iface.nu && !['add', 'replace'].includes(props.existingHosts)) return
+
+ let endpoint = await findExpectedEndpoint(iface)
+ if (!unref(loading)) return endpoint
+
+ if (!endpoint) {
+ endpoint = await createEndpoint(iface)
+ log(host, 'Created new endpoint', 'success')
+ return endpoint
+ } else if (props.existingHosts === 'replace') {
+ endpoint = await updateEndpoint(endpoint)
+ log(host, 'Updated existing endpoint', 'success')
+ }
+
+ return endpoint
+}
+
+/**
+ * Finds or creates a corresponding endpoint to the specified host, interface,
+ * and endpoint model (for the hub host).
+ * @param host Host model.
+ * @param iface Interface model.
+ * @param endpoint Endpoint model.
+ * @return Endpoint model or null.
+ */
+async function findOrCreateHubEndpoint(host, iface, endpoint) {
+ if (!endpoint) return
+ const peer = iface.rel('peer')
+ const hubEndpoint = await findEndpoint(props.interfaceModel, peer)
+ if (!unref(loading)) return
+
+ if (!hubEndpoint) {
+ const params = await buildHubEndpointParams(peer, iface.rel('routing'))
+ if (!unref(loading)) return
+ const url = `/interfaces/${props.interfaceModel.id}/endpoints`
+ await $axiosApi.post(url, params)
+ log(host, 'Created corresponding endpoint', 'success')
+ } else if (props.existingHosts === 'skip') {
+ log(host, 'Found existing corresponding endpoint', 'warning')
+ log(host, 'Skipped', 'warning')
+ } else if (props.existingHosts === 'replace') {
+ const params = await buildHubEndpointParams(peer, iface.rel('routing'))
+ if (!unref(loading)) return
+ const url = `/endpoints/${hubEndpoint.id}`
+ await $axiosApi.post(url, params)
+ log(host, 'Updated corresponding endpoint', 'success')
+ } else {
+ log(host, 'Found existing corresponding endpoint')
+ }
+}
+
+/**
+ * Builds the params to create an endpoint for the specified peer,
+ * corresponding to the specified remote interface routing.
+ * @param peer Peer model.
+ * @param routing Routing model.
+ * @return Params map.
+ */
+async function buildHubEndpointParams(peer, routing) {
+ const { data } = await $axiosApi.get(`/peers/${peer.id}`, {
+ params: { included: 'defaults' },
+ })
+ const { allowed_ips: ips } = normalizeModel(data).rel(
+ 'peer_endpoint_default',
+ ).attr
+ const { address } = routing.meta.expected || routing.attr
+ return {
+ available: true,
+ peer_id: peer.id,
+ preshared_key_copy: true,
+ allowed_ips:
+ ips && ips.length
+ ? ips
+ : address
+ ? address.map((x) => splitAddress(x).ip)
+ : null,
+ }
+}
+
+/**
+ * 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 the list of peers with the specified name.
+ * @param {string} name Peer name.
+ * @return List of peer models.
+ */
+async function findPeers(name) {
+ const { data } = await $axiosApi.get('/peers', {
+ params: { q: `"${name}"`, field: 'name' },
+ })
+ return normalizeModels(data)
+}
+
+/**
+ * Finds the default interface for the specified host.
+ * @param host Host model.
+ * @return Interface model or null.
+ */
+async function findInterface(host) {
+ if (host.nu) return null
+ const { data } = await $axiosApi.get(`/hosts/${host.id}/interfaces/all`, {
+ params: { included: 'peer,routing,expected' },
+ })
+ // reverse to newest first
+ const ifaces = normalizeModels(data).reverse()
+ if (ifaces.length <= 1) return ifaces[0]
+ return ifaces.find(findExpectedEndpoint) || ifaces[0]
+}
+
+/**
+ * Finds the endpoint for the specified interface and peer,
+ * with the 'expected' included set.
+ * @param iface Interface model.
+ * @param [peer] Peer model (defaults to hub peer).
+ * @return Endpoint model or null.
+ */
+async function findExpectedEndpoint(iface, peer) {
+ return findEndpoint(iface, peer, 'expected')
+}
+
+/**
+ * Finds the endpoint for the specified interface and peer.
+ * @param iface Interface model.
+ * @param [peer] Peer model (defaults to hub peer).
+ * @param {string} [included] Included parameter value (eg 'expected').
+ * @return Endpoint model or null.
+ */
+async function findEndpoint(iface, peer, included) {
+ if (iface.nu) return null
+ if (!peer) peer = unref(hubPeer)
+ try {
+ const { data } = await $axiosApi.get(
+ `/interfaces/${iface.id}/endpoints/peers/${peer.id}`,
+ { params: included ? { included } : {}, suppressErrors: true },
+ )
+ return normalizeModel(data)
+ } catch (e) {
+ if (!e.response) throw e
+ return null
+ }
+}
+
+/**
+ * Creates a new host with the specified name.
+ * @param {string} name Host name.
+ * @return Host model.
+ */
+async function createHost(name) {
+ const { data } = await $axiosApi.post('/hosts', { name })
+ return modelNu(modelHost(normalizeModel(data)))
+}
+
+/**
+ * Deletes the specified host.
+ * @param host Host model.
+ */
+async function deleteHost(host) {
+ await $axiosApi.delete(`/hosts/${host.id}/connections`)
+ await $axiosApi.delete(`/hosts/${host.id}`)
+}
+
+/**
+ * Creates a new peer with the specified name.
+ * @param {string} name Peer name.
+ * @return Peer model.
+ */
+async function createPeer(name) {
+ const keyPair = await generateKeyPair()
+ const params = Object.assign({ name }, keyPair.attr)
+ const { data } = await $axiosApi.post('/peers', params)
+ return modelNu(normalizeModel(data))
+}
+
+/**
+ * Deletes the specified peer.
+ * @param peer Peer model.
+ */
+async function deletePeer(peer) {
+ await $axiosApi.delete(`/peers/${peer.id}/connections`)
+ await $axiosApi.delete(`/peers/${peer.id}`)
+}
+
+/**
+ * Creates a new interface for the specified host and peer.
+ * @param host Host model.
+ * @param peer Peer model.
+ * @return Interface model.
+ */
+async function createInterface(host, peer) {
+ const defaults = unref(hubPeer).rel('peer_endpoint_routing_default').attr
+
+ const unavailable = unref(unavailableAddresses)
+ const nextAddresses = unref(lastAddresses)
+ .map((last) => {
+ const a = generateNextAddress(last, null, unavailable, 'up')
+ if (a) {
+ a.subnet = last.subnet
+ a.subnetMask = last.subnetMask
+ }
+ return a
+ })
+ .filter((x) => x)
+
+ unavailable.push(...nextAddresses)
+ lastAddresses.value = nextAddresses
+ const address = nextAddresses.map((x) => {
+ return joinAddress({ ip: x.correctForm(), mask: x.subnetMask })
+ })
+
+ const params = Object.assign({}, defaults, {
+ name: 'wg0',
+ peer_id: peer.id,
+ address,
+ })
+ const { data } = await $axiosApi.post(`/hosts/${host.id}/interfaces`, params)
+ const m = normalizeModel(data)
+
+ const rr = { peer: [peer], routing: [simulateModel({ attr: { address } })] }
+ return simulateModel({ id: m.id, attr: m.attr, rr, nu: true })
+}
+
+/**
+ * Creates a new endpoint for the specified interface.
+ * @param iface Interface model.
+ * @return Endpoint model.
+ */
+async function createEndpoint(iface) {
+ let psk = 'generate'
+ if (props.existingHost === 'add') {
+ const peer = iface.rel('peer')
+ const hubEndpoint = await findEndpoint(props.interfaceModel, peer)
+ if (hubEndpoint) psk = 'copy'
+ }
+
+ const params = await buildEndpointParams(psk)
+ const url = `/interfaces/${iface.id}/endpoints`
+ const { data } = await $axiosApi.post(url, params)
+ return simulateModel({ id: data.data.id, attr: params, nu: true })
+}
+
+/**
+ * Updates the specified endpoint with the hub endpoint defaults.
+ * @param endpoint Endpoint model.
+ * @return Updated endpoint model.
+ */
+async function updateEndpoint(endpoint) {
+ const params = await buildEndpointParams()
+ await $axiosApi.post(`/endpoints/${endpoint.id}`, params)
+ return simulateModel({ id: endpoint.id, attr: params })
+}
+
+/**
+ * Builds the params to create an endpoint for the hub peer.
+ * @param {string} [psk] Preshared key behavior: 'copy' or 'generate' (default).
+ * @return Params map.
+ */
+async function buildEndpointParams(psk = 'generate') {
+ const peer = unref(hubPeer)
+ const defaults = peer.rel('peer_endpoint_default').attr
+ const params = Object.assign({ available: true, peer_id: peer.id }, defaults)
+ if (psk === 'copy') {
+ params.preshared_key_copy = true
+ } else {
+ params.preshared_key = defaults.preshared_key_generate
+ ? await generatePresharedKey()
+ : ''
+ }
+ return params
+}
+
+/**
+ * Generates a new X25519 public key pair.
+ * @return Model with `public_key` and `private_key` attributes.
+ */
+async function generateKeyPair() {
+ const url = '/peers/private-key/generate'
+ const secret = randomBase64(32)
+ const { data } = await $axiosApi.post(url, { secret })
+ return normalizeModel(data)
+}
+
+/**
+ * Generates a new random 256-bit secret key.
+ * @return {string} Base64-encoded key.
+ */
+async function generatePresharedKey() {
+ const url = '/endpoints/preshared-key/generate'
+ const secret = randomBase64(32)
+ const { data } = await $axiosApi.post(url, { secret })
+ return normalizeModel(data).id
+}
+
+/**
+ * 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, '') : ''
+}
+
+/**
+ * Adds the configuration files for specified host and interface to the zip.
+ * @param host Host model.
+ * @param iface Interface model.
+ */
+function addToZip(host, _iface) {
+ if (props.downloadFiles === 'nothing') return
+ log(host, 'Zipped', 'success')
+}
+
+/**
+ * Completes the processing and downloads the zip file.
+ */
+function downloadZip() {
+ const message = buildDoneMessage()
+ toast(message, { variant: / 0 /.test(message) ? 'warning' : 'success' })
+ loading.value = false
+ emit('complete')
+}
+
+/**
+ * Builds the completion message.
+ * @return {string} Completion message.
+ */
+function buildDoneMessage() {
+ if (props.downloadFiles === 'nothing') {
+ const added = data.value.filter((host) => {
+ return host.meta.log.some((x) => x.message === 'Created new host')
+ })
+ if (added.length) return `Added ${added.length} hosts`
+
+ const updated = data.value.filter((host) => {
+ return host.meta.log.some((x) => /^Created|^Updated/.test(x.message))
+ })
+ return `Updated ${updated.length} hosts`
+ }
+
+ 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,308 @@
+<template>
+ <app-panel label="Bulk Upload" :loading="loading">
+ <app-form
+ :input-refs="inputRefs"
+ :cancel="cancel"
+ :submitting="submitting"
+ :submit="submit"
+ :submit-disabled="!hasPlan || !!interfaceErrors.length"
+ submit-label="Add"
+ >
+ <app-message :active="!hasPlan" variant="warning">
+ <router-link to="/admin/billing/setup">Upgrade</router-link> to a paid
+ plan to add hosts by bulk (see
+ <a :href="pricingPage" target="www" rel="noopener">Plans & Pricing</a>
+ page).
+ </app-message>
+ <o-field :message="csvMessage" label="Hosts to Add">
+ <o-input
+ ref="csvInput"
+ v-model="csv"
+ :placeholder="csvPlaceholder"
+ type="textarea"
+ validation-message="required"
+ required
+ autofocus
+ />
+ </o-field>
+ <app-message :active="!!interfaceErrors.length" variant="warning">
+ <div class="content">
+ <div>
+ The selected interface does not appear to be a hub. It should have
+ the following defaults set:
+ </div>
+ <ul>
+ <li v-for="item in interfaceErrors" :key="item">{{ item }}</li>
+ </ul>
+ </div>
+ </app-message>
+ <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
+ />
+ <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>
+ <o-radio v-model="downloadFiles" native-value="nothing">
+ Nothing
+ </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>
+ <o-field label="Existing Hosts or Peers" class="radio-stack">
+ <o-radio v-model="existingHosts" native-value="skip">
+ Skip and warn
+ </o-radio>
+ </o-field>
+ <o-field class="radio-stack">
+ <o-radio v-model="existingHosts" native-value="download">
+ Don't modify, but include in download
+ </o-radio>
+ </o-field>
+ <o-field class="radio-stack">
+ <o-radio v-model="existingHosts" native-value="add">
+ Add connection to hub if missing
+ </o-radio>
+ </o-field>
+ <o-field class="radio-stack">
+ <o-radio v-model="existingHosts" native-value="replace">
+ Replace connection to hub with latest defaults
+ </o-radio>
+ </o-field>
+ <o-field>
+ <o-radio v-model="existingHosts" native-value="delete">
+ Delete it and all connections and re-create from scratch
+ </o-radio>
+ </o-field>
+ </app-form>
+ <host-bulk-upload-modal
+ v-model="submitting"
+ :hosts="hosts"
+ :interface-model="iface"
+ :download-files="downloadFiles"
+ :invalid-chars="invalidChars"
+ :existing-hosts="existingHosts"
+ />
+ </app-panel>
+</template>
+
+<script setup>
+import { computed, inject, onMounted, ref, unref, watch } from 'vue'
+import { useInputRefs } from '@/mixins/app/use-input-refs'
+import { useFeaturesStore } from '@/stores/features'
+import { env } from '@/utils/env'
+import { splitAddress } from '@/utils/ip'
+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')
+const { inputRefs } = useInputRefs(['csvInput', 'hostInput', 'interfaceInput'])
+const features = useFeaturesStore()
+
+/** Selected host ID. */
+const hostId = ref('')
+const interfaceId = ref('')
+/** Selected interface model. */
+const iface = ref(props.interfaceModel)
+/** List of error messages to display about selected interface. */
+const interfaceErrors = ref([])
+
+/** Raw CSV input text. */
+const csv = ref('')
+/** 'Download' radio button value. */
+const downloadFiles = ref('agent')
+/** 'Invalid name' radio button value. */
+const invalidChars = ref('skip')
+/** 'Existing host or peer' radio button value. */
+const existingHosts = ref('skip')
+/** True if submitting form. */
+const submitting = ref(false)
+
+/** Help message for CSV input. */
+const csvMessage = ref(`
+List of hosts to add (one host per line).
+A new connection to the existing hub host will be created for each listed host,
+using the hub interface's defaults.
+`)
+/** Placeholder text for CSV input. */
+const csvPlaceholder = ref(
+ `
+Alice's Laptop
+Bob's Phone
+Carla's Workstation
+`.trim(),
+)
+
+/** Parsed list of host names. */
+const hosts = computed(() => {
+ return unref(csv)
+ .split(/\r?\n/)
+ .map((x) => x.trim())
+ .filter((x) => x)
+})
+/** True if user on plan with unlimited hosts. */
+const hasPlan = computed(() => !features.hostLimit || features.hostLimit >= 100)
+/** URL to pricing page. */
+const pricingPage = computed(() => `${env.wwwUrl}/pricing/`)
+
+watch(hostId, selectDefaultInterface)
+watch(interfaceId, loadInterface)
+watch(iface, validateInterface)
+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)
+}
+
+/**
+ * Validates the currently selected interface, updating the errors list.
+ * @param iface Interface model.
+ */
+async function validateInterface(iface) {
+ const { id } = iface.rel('peer')
+ if (!id) {
+ interfaceErrors.value = []
+ return
+ }
+
+ const { data } = await $axiosApi.get(`/peers/${id}`, {
+ params: { included: 'defaults' },
+ })
+ const model = normalizeModel(data)
+ const endpoint = model.rel('peer_endpoint_default').attr || {}
+ const routing = model.rel('peer_endpoint_routing_default').attr || {}
+ const errors = []
+
+ const hubTypes = ['hub-and-spoke', 'internet-to-point', 'site-to-point']
+ if (!hubTypes.includes(endpoint.wizard)) {
+ errors.push(
+ 'Type: either Hub (in Hub-and-Spoke), Internet (in Point-to-Internet), or Site (in Point-to-Site)',
+ )
+ }
+
+ if (!endpoint.allowed_ips || !endpoint.allowed_ips.length) {
+ errors.push('Allowed IPs')
+ }
+ if (!(endpoint.hostname || endpoint.ip)) errors.push('Hostname')
+ if (!endpoint.port) errors.push('Port')
+
+ const bigSubnets =
+ routing.subnet &&
+ routing.subnet.length &&
+ routing.subnet.every((x) => {
+ const { mask, v4, v6 } = splitAddress(x)
+ return (v4 && mask < 30) || (v6 && mask < 126)
+ })
+ if (!bigSubnets) {
+ errors.push(
+ 'Network Addresses: each with a mask less than /30 (for IPv4, or /126 for IPv6)',
+ )
+ }
+
+ interfaceErrors.value = errors
+}
+
+/**
+ * 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>