From 4a53a5207aafcf0b92fe32ae8b00e37f9b9ba5a7 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Fri, 23 Jun 2023 19:24:59 +0200 Subject: [PATCH] peer creation and deletion --- cmd/wg-portal/main.go | 1 - .../src/components/InterfaceEditModal.vue | 67 +--- frontend/src/components/PeerEditModal.vue | 299 ++++++++++++------ frontend/src/helpers/models.js | 120 +++++++ frontend/src/helpers/validators.js | 14 + frontend/src/stores/interfaces.js | 26 +- frontend/src/stores/peers.js | 80 ++++- frontend/src/stores/profile.js | 4 +- frontend/src/stores/users.js | 6 +- frontend/src/views/InterfaceView.vue | 2 +- internal/adapters/database.go | 88 +++++- .../app/api/v0/handlers/endpoint_peers.go | 8 +- internal/app/api/v0/model/model_options.go | 20 +- internal/app/api/v0/model/models.go | 4 +- internal/app/api/v0/model/models_peer.go | 10 +- internal/app/migrate_v1.go | 5 +- internal/app/repos.go | 4 +- internal/app/wireguard/repos.go | 1 + internal/app/wireguard/wireguard.go | 219 +++++++++++-- internal/domain/ip.go | 12 +- internal/domain/peer.go | 12 +- 21 files changed, 760 insertions(+), 242 deletions(-) create mode 100644 frontend/src/helpers/models.js create mode 100644 frontend/src/helpers/validators.js diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go index f62d675..39e6d08 100644 --- a/cmd/wg-portal/main.go +++ b/cmd/wg-portal/main.go @@ -69,7 +69,6 @@ func main() { backend, err := app.New(cfg, eventBus, authenticator, userManager, wireGuardManager, statisticsCollector, templateManager) - internal.AssertNoError(err) err = backend.Startup(ctx) internal.AssertNoError(err) diff --git a/frontend/src/components/InterfaceEditModal.vue b/frontend/src/components/InterfaceEditModal.vue index 2a06b04..a1628c5 100644 --- a/frontend/src/components/InterfaceEditModal.vue +++ b/frontend/src/components/InterfaceEditModal.vue @@ -5,8 +5,10 @@ import {computed, ref, watch} from "vue"; import { useI18n } from 'vue-i18n'; import { notify } from "@kyvg/vue3-notification"; import Vue3TagsInput from 'vue3-tags-input'; +import { validateCIDR, validateIP, validateDomain } from '@/helpers/validators'; import isCidr from "is-cidr"; import {isIP} from 'is-ip'; +import { freshInterface } from '@/helpers/models'; const { t } = useI18n() @@ -34,54 +36,10 @@ const title = computed(() => { return t("interfaces.interface.new") }) -const formData = ref(freshFormData()) +const formData = ref(freshInterface()) // functions -function freshFormData() { - return { - Disabled: false, - DisplayName: "", - Identifier: "", - Mode: "server", - - PublicKey: "", - PrivateKey: "", - - ListenPort: 51820, - Addresses: [], - DnsStr: [], - DnsSearch: [], - - Mtu: 0, - FirewallMark: 0, - RoutingTable: "", - - PreUp: "", - PostUp: "", - PreDown: "", - PostDown: "", - - SaveConfig: false, - - // Peer defaults - - PeerDefNetwork: [], - PeerDefDns: [], - PeerDefDnsSearch: [], - PeerDefEndpoint: "", - PeerDefAllowedIPs: [], - PeerDefMtu: 0, - PeerDefPersistentKeepalive: 0, - PeerDefFirewallMark: 0, - PeerDefRoutingTable: "", - PeerDefPreUp: "", - PeerDefPostUp: "", - PeerDefPreDown: "", - PeerDefPostDown: "" - } -} - watch(() => props.visible, async (newValue, oldValue) => { if (oldValue === false && newValue === true) { // if modal is shown console.log(selectedInterface.value) @@ -170,7 +128,7 @@ watch(() => props.visible, async (newValue, oldValue) => { ) function close() { - formData.value = freshFormData() + formData.value = freshInterface() emit('close') } @@ -264,20 +222,7 @@ function handleChangePeerDefDns(tags) { } function handleChangePeerDefDnsSearch(tags) { - formData.value.DnsSearch = tags -} - -function validateCIDR(value) { - return isCidr(value) !== 0 -} - -function validateIP(value) { - return isIP(value) -} - -function validateDomain(value) { - console.log("validating: ", value) - return true + formData.value.PeerDefDnsSearch = tags } async function save() { @@ -289,6 +234,7 @@ async function save() { } close() } catch (e) { + console.log(e) notify({ title: "Backend Connection Failure", text: "Failed to save interface!", @@ -302,6 +248,7 @@ async function del() { await interfaces.DeleteInterface(selectedInterface.value.Identifier) close() } catch (e) { + console.log(e) notify({ title: "Backend Connection Failure", text: "Failed to delete interface!", diff --git a/frontend/src/components/PeerEditModal.vue b/frontend/src/components/PeerEditModal.vue index a1fb194..04b8d13 100644 --- a/frontend/src/components/PeerEditModal.vue +++ b/frontend/src/components/PeerEditModal.vue @@ -5,6 +5,11 @@ import {interfaceStore} from "@/stores/interfaces"; import {computed, ref, watch} from "vue"; import { useI18n } from 'vue-i18n'; import { notify } from "@kyvg/vue3-notification"; +import Vue3TagsInput from "vue3-tags-input"; +import { validateCIDR, validateIP, validateDomain } from '@/helpers/validators'; +import isCidr from "is-cidr"; +import {isIP} from 'is-ip'; +import { freshPeer } from '@/helpers/models'; const { t } = useI18n() @@ -52,83 +57,7 @@ const title = computed(() => { } }) -const formData = ref(freshFormData()) - - -function freshFormData() { - return { - Disabled: false, - IgnoreGlobalSettings: true, - - Endpoint: { - Value: "", - Overridable: false, - }, - AllowedIPsStr: { - Value: "", - Overridable: false, - }, - ExtraAllowedIPsStr: "", - PrivateKey: "", - PublicKey: "", - PresharedKey: "", - PersistentKeepalive: { - Value: 0, - Overridable: false, - }, - - DisplayName: "", - Identifier: "", - UserIdentifier: "", - - InterfaceConfig: { - PublicKey: { - Value: "", - Overridable: false, - }, - AddressStr: { - Value: "", - Overridable: false, - }, - DnsStr: { - Value: "", - Overridable: false, - }, - DnsSearchStr: { - Value: "", - Overridable: false, - }, - Mtu: { - Value: 0, - Overridable: false, - }, - FirewallMark: { - Value: 0, - Overridable: false, - }, - RoutingTable: { - Value: "", - Overridable: false, - }, - PreUp: { - Value: "", - Overridable: false, - }, - PostUp: { - Value: "", - Overridable: false, - }, - PreDown: { - Value: "", - Overridable: false, - }, - PostDown: { - Value: "", - Overridable: false, - }, - } - } -} +const formData = ref(freshPeer()) // functions @@ -139,14 +68,72 @@ watch(() => props.visible, async (newValue, oldValue) => { if (!selectedPeer.value) { await peers.PreparePeer(selectedInterface.value.Identifier) - formData.value.Disabled = peers.Prepared.Disabled formData.value.Identifier = peers.Prepared.Identifier formData.value.DisplayName = peers.Prepared.DisplayName + formData.value.UserIdentifier = peers.Prepared.UserIdentifier + formData.value.InterfaceIdentifier = peers.Prepared.InterfaceIdentifier + formData.value.Disabled = peers.Prepared.Disabled + formData.value.ExpiresAt = peers.Prepared.ExpiresAt + formData.value.Notes = peers.Prepared.Notes + + formData.value.Endpoint = peers.Prepared.Endpoint + formData.value.EndpointPublicKey = peers.Prepared.EndpointPublicKey + formData.value.AllowedIPs = peers.Prepared.AllowedIPs + formData.value.ExtraAllowedIPs = peers.Prepared.ExtraAllowedIPs + formData.value.PresharedKey = peers.Prepared.PresharedKey + formData.value.PersistentKeepalive = peers.Prepared.PersistentKeepalive + + formData.value.PrivateKey = peers.Prepared.PrivateKey + formData.value.PublicKey = peers.Prepared.PublicKey + + formData.value.Mode = peers.Prepared.Mode + + formData.value.Addresses = peers.Prepared.Addresses + formData.value.CheckAliveAddress = peers.Prepared.CheckAliveAddress + formData.value.Dns = peers.Prepared.Dns + formData.value.DnsSearch = peers.Prepared.DnsSearch + formData.value.Mtu = peers.Prepared.Mtu + formData.value.FirewallMark = peers.Prepared.FirewallMark + formData.value.RoutingTable = peers.Prepared.RoutingTable + + formData.value.PreUp = peers.Prepared.PreUp + formData.value.PostUp = peers.Prepared.PostUp + formData.value.PreDown = peers.Prepared.PreDown + formData.value.PostDown = peers.Prepared.PostDown } else { // fill existing data - formData.value.Disabled = selectedPeer.value.Disabled formData.value.Identifier = selectedPeer.value.Identifier formData.value.DisplayName = selectedPeer.value.DisplayName + formData.value.UserIdentifier = selectedPeer.value.UserIdentifier + formData.value.InterfaceIdentifier = selectedPeer.value.InterfaceIdentifier + formData.value.Disabled = selectedPeer.value.Disabled + formData.value.ExpiresAt = selectedPeer.value.ExpiresAt + formData.value.Notes = selectedPeer.value.Notes + + formData.value.Endpoint = selectedPeer.value.Endpoint + formData.value.EndpointPublicKey = selectedPeer.value.EndpointPublicKey + formData.value.AllowedIPs = selectedPeer.value.AllowedIPs + formData.value.ExtraAllowedIPs = selectedPeer.value.ExtraAllowedIPs + formData.value.PresharedKey = selectedPeer.value.PresharedKey + formData.value.PersistentKeepalive = selectedPeer.value.PersistentKeepalive + + formData.value.PrivateKey = selectedPeer.value.PrivateKey + formData.value.PublicKey = selectedPeer.value.PublicKey + + formData.value.Mode = selectedPeer.value.Mode + + formData.value.Addresses = selectedPeer.value.Addresses + formData.value.CheckAliveAddress = selectedPeer.value.CheckAliveAddress + formData.value.Dns = selectedPeer.value.Dns + formData.value.DnsSearch = selectedPeer.value.DnsSearch + formData.value.Mtu = selectedPeer.value.Mtu + formData.value.FirewallMark = selectedPeer.value.FirewallMark + formData.value.RoutingTable = selectedPeer.value.RoutingTable + + formData.value.PreUp = selectedPeer.value.PreUp + formData.value.PostUp = selectedPeer.value.PostUp + formData.value.PreDown = selectedPeer.value.PreDown + formData.value.PostDown = selectedPeer.value.PostDown } } @@ -154,10 +141,114 @@ watch(() => props.visible, async (newValue, oldValue) => { ) function close() { - formData.value = freshFormData() + formData.value = freshPeer() emit('close') } +function handleChangeAddresses(tags) { + let validInput = true + tags.forEach(tag => { + if(isCidr(tag) === 0) { + validInput = false + notify({ + title: "Invalid CIDR", + text: tag + " is not a valid IP address", + type: 'error', + }) + } + }) + if(validInput) { + formData.value.Addresses = tags + } +} + +function handleChangeAllowedIPs(tags) { + let validInput = true + tags.forEach(tag => { + if(isCidr(tag) === 0) { + validInput = false + notify({ + title: "Invalid CIDR", + text: tag + " is not a valid IP address", + type: 'error', + }) + } + }) + if(validInput) { + formData.value.AllowedIPs = tags + } +} + +function handleChangeExtraAllowedIPs(tags) { + let validInput = true + tags.forEach(tag => { + if(isCidr(tag) === 0) { + validInput = false + notify({ + title: "Invalid CIDR", + text: tag + " is not a valid IP address", + type: 'error', + }) + } + }) + if(validInput) { + formData.value.ExtraAllowedIPs = tags + } +} + +function handleChangeDns(tags) { + let validInput = true + tags.forEach(tag => { + if(!isIP(tag)) { + validInput = false + notify({ + title: "Invalid IP", + text: tag + " is not a valid IP address", + type: 'error', + }) + } + }) + if(validInput) { + formData.value.Dns = tags + } +} + +function handleChangeDnsSearch(tags) { + formData.value.DnsSearch = tags +} + +async function save() { + try { + if (props.peerId!=='#NEW#') { + await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value) + } else { + await peers.CreatePeer(selectedInterface.value.Identifier, formData.value) + } + close() + } catch (e) { + console.log(e) + notify({ + title: "Backend Connection Failure", + text: "Failed to save peer!", + type: 'error', + }) + } +} + +async function del() { + try { + await peers.DeletePeer(selectedPeer.value.Identifier) + close() + } catch (e) { + console.log(e) + notify({ + title: "Backend Connection Failure", + text: "Failed to delete peer!", + type: 'error', + }) + } +} + diff --git a/frontend/src/helpers/models.js b/frontend/src/helpers/models.js new file mode 100644 index 0000000..feb6ce5 --- /dev/null +++ b/frontend/src/helpers/models.js @@ -0,0 +1,120 @@ + +export function freshInterface() { + return { + Disabled: false, + DisplayName: "", + Identifier: "", + Mode: "server", + + PublicKey: "", + PrivateKey: "", + + ListenPort: 51820, + Addresses: [], + DnsStr: [], + DnsSearch: [], + + Mtu: 0, + FirewallMark: 0, + RoutingTable: "", + + PreUp: "", + PostUp: "", + PreDown: "", + PostDown: "", + + SaveConfig: false, + + // Peer defaults + + PeerDefNetwork: [], + PeerDefDns: [], + PeerDefDnsSearch: [], + PeerDefEndpoint: "", + PeerDefAllowedIPs: [], + PeerDefMtu: 0, + PeerDefPersistentKeepalive: 0, + PeerDefFirewallMark: 0, + PeerDefRoutingTable: "", + PeerDefPreUp: "", + PeerDefPostUp: "", + PeerDefPreDown: "", + PeerDefPostDown: "" + } +} + +export function freshPeer() { + return { + Identifier: "", + DisplayName: "", + UserIdentifier: "", + InterfaceIdentifier: "", + Disabled: false, + ExpiresAt: null, + Notes: "", + + Endpoint: { + Value: "", + Overridable: true, + }, + EndpointPublicKey: { + Value: "", + Overridable: true, + }, + AllowedIPs: { + Value: [], + Overridable: true, + }, + ExtraAllowedIPs: [], + PresharedKey: "", + PersistentKeepalive: { + Value: 0, + Overridable: true, + }, + + PrivateKey: "", + PublicKey: "", + + Mode: "client", + + Addresses: [], + CheckAliveAddress: "", + Dns: { + Value: [], + Overridable: true, + }, + DnsSearch: { + Value: [], + Overridable: true, + }, + Mtu: { + Value: 0, + Overridable: true, + }, + FirewallMark: { + Value: 0, + Overridable: true, + }, + RoutingTable: { + Value: "", + Overridable: true, + }, + + PreUp: { + Value: "", + Overridable: true, + }, + PostUp: { + Value: "", + Overridable: true, + }, + PreDown: { + Value: "", + Overridable: true, + }, + PostDown: { + Value: "", + Overridable: true, + } + } +} \ No newline at end of file diff --git a/frontend/src/helpers/validators.js b/frontend/src/helpers/validators.js new file mode 100644 index 0000000..90766ba --- /dev/null +++ b/frontend/src/helpers/validators.js @@ -0,0 +1,14 @@ +import isCidr from "is-cidr"; +import {isIP} from 'is-ip'; + +export function validateCIDR(value) { + return isCidr(value) !== 0 +} + +export function validateIP(value) { + return isIP(value) +} + +export function validateDomain(value) { + return true +} \ No newline at end of file diff --git a/frontend/src/stores/interfaces.js b/frontend/src/stores/interfaces.js index 38921fd..f09cc22 100644 --- a/frontend/src/stores/interfaces.js +++ b/frontend/src/stores/interfaces.js @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import {apiWrapper} from '@/helpers/fetch-wrapper' import {notify} from "@kyvg/vue3-notification"; +import { freshInterface } from '@/helpers/models'; const baseUrl = `/interface` @@ -9,12 +10,9 @@ export const interfaceStore = defineStore({ id: 'interfaces', state: () => ({ interfaces: [], - prepared: { - Identifier: "", - Type: "server", - }, + prepared: freshInterface(), configuration: "", - selected: "wg0", + selected: "", fetching: false, }), getters: { @@ -30,6 +28,11 @@ export const interfaceStore = defineStore({ actions: { setInterfaces(interfaces) { this.interfaces = interfaces + if (this.interfaces.length > 0) { + this.selected = this.interfaces[0].Identifier + } else { + this.selected = "" + } this.fetching = false }, async LoadInterfaces() { @@ -55,7 +58,7 @@ export const interfaceStore = defineStore({ return apiWrapper.get(`${baseUrl}/prepare`) .then(this.setPreparedInterface) .catch(error => { - this.prepared = {} + this.prepared = freshInterface() console.log("Failed to load prepared interface: ", error) notify({ title: "Backend Connection Failure", @@ -64,7 +67,7 @@ export const interfaceStore = defineStore({ }) }, async InterfaceConfig(id) { - return apiWrapper.get(`${baseUrl}/config/${id}`) + return apiWrapper.get(`${baseUrl}/config/${encodeURIComponent(id)}`) .then(this.setInterfaceConfig) .catch(error => { this.prepared = {} @@ -77,9 +80,14 @@ export const interfaceStore = defineStore({ }, async DeleteInterface(id) { this.fetching = true - return apiWrapper.delete(`${baseUrl}/${id}`) + return apiWrapper.delete(`${baseUrl}/${encodeURIComponent(id)}`) .then(() => { this.interfaces = this.interfaces.filter(i => i.Identifier !== id) + if (this.interfaces.length > 0) { + this.selected = this.interfaces[0].Identifier + } else { + this.selected = "" + } this.fetching = false }) .catch(error => { @@ -90,7 +98,7 @@ export const interfaceStore = defineStore({ }, async UpdateInterface(id, formData) { this.fetching = true - return apiWrapper.put(`${baseUrl}/${id}`, formData) + return apiWrapper.put(`${baseUrl}/${encodeURIComponent(id)}`, formData) .then(iface => { let idx = this.interfaces.findIndex((i) => i.Identifier === id) this.interfaces[idx] = iface diff --git a/frontend/src/stores/peers.js b/frontend/src/stores/peers.js index 01de482..8461536 100644 --- a/frontend/src/stores/peers.js +++ b/frontend/src/stores/peers.js @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import {apiWrapper} from "../helpers/fetch-wrapper"; import {notify} from "@kyvg/vue3-notification"; import {interfaceStore} from "./interfaces"; +import { freshPeer } from '@/helpers/models'; const baseUrl = `/peer` @@ -9,9 +10,8 @@ export const peerStore = defineStore({ id: 'peers', state: () => ({ peers: [], - prepared: { - Identifier: "", - }, + peer: freshPeer(), + prepared: freshPeer(), filter: "", pageSize: 10, pageOffset: 0, @@ -75,14 +75,18 @@ export const peerStore = defineStore({ this.calculatePages() this.fetching = false }, + setPeer(peer) { + this.peer = peer + this.fetching = false + }, setPreparedPeer(peer) { this.prepared = peer; }, async PreparePeer(interfaceId) { - return apiWrapper.get(`${baseUrl}/iface/${iface.Identifier}/prepare`) + return apiWrapper.get(`${baseUrl}/iface/${encodeURIComponent(interfaceId)}/prepare`) .then(this.setPreparedPeer) .catch(error => { - this.prepared = {} + this.prepared = freshPeer() console.log("Failed to load prepared peer: ", error) notify({ title: "Backend Connection Failure", @@ -90,14 +94,70 @@ export const peerStore = defineStore({ }) }) }, - async LoadPeers() { - let iface = interfaceStore().GetSelected - if (!iface) { - return // no interface, nothing to load + async LoadPeer(id) { + this.fetching = true + return apiWrapper.get(`${baseUrl}/${encodeURIComponent(id)}`) + .then(this.setPeer) + .catch(error => { + this.setPeers([]) + console.log("Failed to load peer: ", error) + notify({ + title: "Backend Connection Failure", + text: "Failed to load peer!", + }) + }) + }, + async DeletePeer(id) { + this.fetching = true + return apiWrapper.delete(`${baseUrl}/${encodeURIComponent(id)}`) + .then(() => { + this.peers = this.peers.filter(p => p.Identifier !== id) + this.fetching = false + }) + .catch(error => { + this.fetching = false + console.log(error) + throw new Error(error) + }) + }, + async UpdatePeer(id, formData) { + this.fetching = true + return apiWrapper.put(`${baseUrl}/${encodeURIComponent(id)}`, formData) + .then(peer => { + let idx = this.peers.findIndex((p) => p.Identifier === id) + this.peers[idx] = peer + this.fetching = false + }) + .catch(error => { + this.fetching = false + console.log(error) + throw new Error(error) + }) + }, + async CreatePeer(interfaceId, formData) { + this.fetching = true + return apiWrapper.post(`${baseUrl}/iface/${encodeURIComponent(interfaceId)}/new`, formData) + .then(peer => { + this.peers.push(peer) + this.fetching = false + }) + .catch(error => { + this.fetching = false + console.log(error) + throw new Error(error) + }) + }, + async LoadPeers(interfaceId) { + // if no interfaceId is given, use the currently selected interface + if (!interfaceId) { + interfaceId = interfaceStore().GetSelected.Identifier + if (!interfaceId) { + return // no interface, nothing to load + } } this.fetching = true - return apiWrapper.get(`${baseUrl}/iface/${iface.Identifier}/all`) + return apiWrapper.get(`${baseUrl}/iface/${encodeURIComponent(interfaceId)}/all`) .then(this.setPeers) .catch(error => { this.setPeers([]) diff --git a/frontend/src/stores/profile.js b/frontend/src/stores/profile.js index 4708824..cfef6b8 100644 --- a/frontend/src/stores/profile.js +++ b/frontend/src/stores/profile.js @@ -79,7 +79,7 @@ export const profileStore = defineStore({ async LoadPeers() { this.fetching = true let currentUser = authStore().user.Identifier - return apiWrapper.get(`${baseUrl}/${currentUser}/peers`) + return apiWrapper.get(`${baseUrl}/${encodeURIComponent(currentUser)}/peers`) .then(this.setPeers) .catch(error => { this.setPeers([]) @@ -93,7 +93,7 @@ export const profileStore = defineStore({ async LoadUser() { this.fetching = true let currentUser = authStore().user.Identifier - return apiWrapper.get(`${baseUrl}/${currentUser}`) + return apiWrapper.get(`${baseUrl}/${encodeURIComponent(currentUser)}`) .then(this.setUser) .catch(error => { this.setUser({}) diff --git a/frontend/src/stores/users.js b/frontend/src/stores/users.js index 5d1516d..87e3c85 100644 --- a/frontend/src/stores/users.js +++ b/frontend/src/stores/users.js @@ -91,7 +91,7 @@ export const userStore = defineStore({ }, async DeleteUser(id) { this.fetching = true - return apiWrapper.delete(`${baseUrl}/` + id) + return apiWrapper.delete(`${baseUrl}/${encodeURIComponent(id)}`) .then(() => { this.users = this.users.filter(u => u.Identifier !== id) this.fetching = false @@ -104,7 +104,7 @@ export const userStore = defineStore({ }, async UpdateUser(id, formData) { this.fetching = true - return apiWrapper.put(`${baseUrl}/` + id, formData) + return apiWrapper.put(`${baseUrl}/${encodeURIComponent(id)}`, formData) .then(user => { let idx = this.users.findIndex((u) => u.Identifier === id) this.users[idx] = user @@ -131,7 +131,7 @@ export const userStore = defineStore({ }, async LoadUserPeers(id) { this.fetching = true - return apiWrapper.get(`${baseUrl}/${id}/peers`) + return apiWrapper.get(`${baseUrl}/${encodeURIComponent(id)}/peers`) .then(this.setUserPeers) .catch(error => { this.setUserPeers([]) diff --git a/frontend/src/views/InterfaceView.vue b/frontend/src/views/InterfaceView.vue index a76a96c..5ea3b92 100644 --- a/frontend/src/views/InterfaceView.vue +++ b/frontend/src/views/InterfaceView.vue @@ -289,7 +289,7 @@ onMounted(async () => { {{ ip }} - {{peer.Endpoint}} + {{peer.Endpoint.Value}} {{peer.LastConnected}} diff --git a/internal/adapters/database.go b/internal/adapters/database.go index 838abab..d44ec73 100644 --- a/internal/adapters/database.go +++ b/internal/adapters/database.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "github.com/sirupsen/logrus" + "gorm.io/gorm/clause" "gorm.io/gorm/logger" "gorm.io/gorm/utils" "os" @@ -317,7 +318,12 @@ func (r *SqlRepo) DeleteInterface(ctx context.Context, id domain.InterfaceIdenti return err } - err = r.db.WithContext(ctx).Delete(&domain.Interface{}, id).Error + err = r.db.WithContext(ctx).Delete(&domain.InterfaceStatus{InterfaceId: id}).Error + if err != nil { + return err + } + + err = r.db.WithContext(ctx).Debug().Select(clause.Associations).Delete(&domain.Interface{Identifier: id}).Error if err != nil { return err } @@ -359,7 +365,11 @@ func (r *SqlRepo) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIden func (r *SqlRepo) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) { var peer domain.Peer - err := r.db.WithContext(ctx).Where("identifier = ?", id).Find(&peer).Error + err := r.db.WithContext(ctx).Preload("Addresses").First(&peer, id).Error + + if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { + return nil, domain.ErrNotFound + } if err != nil { return nil, err } @@ -478,7 +488,19 @@ func (r *SqlRepo) upsertPeer(tx *gorm.DB, peer *domain.Peer) error { } func (r *SqlRepo) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error { - err := r.db.WithContext(ctx).Delete(&domain.Peer{}, id).Error + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := r.db.WithContext(ctx).Delete(&domain.PeerStatus{PeerId: id}).Error + if err != nil { + return err + } + + err = r.db.WithContext(ctx).Select(clause.Associations).Delete(&domain.Peer{Identifier: id}).Error + if err != nil { + return err + } + + return nil + }) if err != nil { return err } @@ -486,6 +508,66 @@ func (r *SqlRepo) DeletePeer(ctx context.Context, id domain.PeerIdentifier) erro return nil } +func (r *SqlRepo) GetPeerIps(ctx context.Context) (map[domain.PeerIdentifier][]domain.Cidr, error) { + var ips []struct { + domain.Cidr + PeerId domain.PeerIdentifier `gorm:"column:peer_identifier"` + } + + err := r.db.WithContext(ctx). + Table("peer_addresses"). + Joins("LEFT JOIN cidrs ON peer_addresses.cidr_cidr = cidrs.cidr"). + Scan(&ips).Error + if err != nil { + return nil, err + } + + result := make(map[domain.PeerIdentifier][]domain.Cidr) + for _, ip := range ips { + result[ip.PeerId] = append(result[ip.PeerId], ip.Cidr) + } + return result, nil +} + +func (r *SqlRepo) GetUsedIpsPerSubnet(ctx context.Context) (map[domain.Cidr][]domain.Cidr, error) { + var peerIps []struct { + domain.Cidr + PeerId domain.PeerIdentifier `gorm:"column:peer_identifier"` + } + + err := r.db.WithContext(ctx). + Table("peer_addresses"). + Joins("LEFT JOIN cidrs ON peer_addresses.cidr_cidr = cidrs.cidr"). + Scan(&peerIps).Error + if err != nil { + return nil, fmt.Errorf("failed to fetch peer IP's: %w", err) + } + + var interfaceIps []struct { + domain.Cidr + InterfaceId domain.InterfaceIdentifier `gorm:"column:interface_identifier"` + } + + err = r.db.WithContext(ctx). + Table("interface_addresses"). + Joins("LEFT JOIN cidrs ON interface_addresses.cidr_cidr = cidrs.cidr"). + Scan(&interfaceIps).Error + if err != nil { + return nil, fmt.Errorf("failed to fetch interface IP's: %w", err) + } + + result := make(map[domain.Cidr][]domain.Cidr) + for _, ip := range interfaceIps { + networkAddr := ip.Cidr.NetworkAddr() + result[networkAddr] = append(result[networkAddr], ip.Cidr) + } + for _, ip := range peerIps { + networkAddr := ip.Cidr.NetworkAddr() + result[networkAddr] = append(result[networkAddr], ip.Cidr) + } + return result, nil +} + // endregion peers // region users diff --git a/internal/app/api/v0/handlers/endpoint_peers.go b/internal/app/api/v0/handlers/endpoint_peers.go index 57a6ef0..62764bc 100644 --- a/internal/app/api/v0/handlers/endpoint_peers.go +++ b/internal/app/api/v0/handlers/endpoint_peers.go @@ -194,12 +194,12 @@ func (e peerEndpoint) handleUpdatePut() gin.HandlerFunc { return } - if p.InterfaceIdentifier != peerId { + if p.Identifier != peerId { c.JSON(http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "peer id mismatch"}) return } - updatedPeer, err := e.app.UpdateInterface(ctx, model.NewDomainPeer(&p)) + updatedPeer, err := e.app.UpdatePeer(ctx, model.NewDomainPeer(&p)) if err != nil { c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) return @@ -232,9 +232,7 @@ func (e peerEndpoint) handleDelete() gin.HandlerFunc { err := e.app.DeletePeer(ctx, domain.PeerIdentifier(id)) if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{ - Code: http.StatusInternalServerError, Message: err.Error(), - }) + c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) return } diff --git a/internal/app/api/v0/model/model_options.go b/internal/app/api/v0/model/model_options.go index b40e9db..b0be72f 100644 --- a/internal/app/api/v0/model/model_options.go +++ b/internal/app/api/v0/model/model_options.go @@ -6,8 +6,8 @@ import ( ) type StringConfigOption struct { - Value string `json:"value"` - Overridable bool `json:"overridable"` + Value string `json:"Value"` + Overridable bool `json:"Overridable"` } func NewStringConfigOption(value string, overridable bool) StringConfigOption { @@ -32,8 +32,8 @@ func StringConfigOptionToDomain(opt StringConfigOption) domain.StringConfigOptio } type StringSliceConfigOption struct { - Value []string `json:"value"` - Overridable bool `json:"overridable"` + Value []string `json:"Value"` + Overridable bool `json:"Overridable"` } func NewStringSliceConfigOption(value []string, overridable bool) StringSliceConfigOption { @@ -58,8 +58,8 @@ func StringSliceConfigOptionToDomain(opt StringSliceConfigOption) domain.StringC } type IntConfigOption struct { - Value int `json:"value"` - Overridable bool `json:"overridable"` + Value int `json:"Value"` + Overridable bool `json:"Overridable"` } func NewIntConfigOption(value int, overridable bool) IntConfigOption { @@ -84,8 +84,8 @@ func IntConfigOptionToDomain(opt IntConfigOption) domain.IntConfigOption { } type Int32ConfigOption struct { - Value int32 `json:"value"` - Overridable bool `json:"overridable"` + Value int32 `json:"Value"` + Overridable bool `json:"Overridable"` } func NewInt32ConfigOption(value int32, overridable bool) Int32ConfigOption { @@ -110,8 +110,8 @@ func Int32ConfigOptionToDomain(opt Int32ConfigOption) domain.Int32ConfigOption { } type BoolConfigOption struct { - Value bool `json:"value"` - Overridable bool `json:"overridable"` + Value bool `json:"Value"` + Overridable bool `json:"Overridable"` } func NewBoolConfigOption(value bool, overridable bool) BoolConfigOption { diff --git a/internal/app/api/v0/model/models.go b/internal/app/api/v0/model/models.go index 40f2491..95c9cfc 100644 --- a/internal/app/api/v0/model/models.go +++ b/internal/app/api/v0/model/models.go @@ -1,6 +1,6 @@ package model type Error struct { - Code int `json:"code"` - Message string `json:"message"` + Code int `json:"Code"` + Message string `json:"Message"` } diff --git a/internal/app/api/v0/model/models_peer.go b/internal/app/api/v0/model/models_peer.go index fdd4a40..aa9f46f 100644 --- a/internal/app/api/v0/model/models_peer.go +++ b/internal/app/api/v0/model/models_peer.go @@ -13,11 +13,11 @@ type Peer struct { InterfaceIdentifier string `json:"InterfaceIdentifier"` // the interface id Disabled bool `json:"Disabled"` // flag that specifies if the peer is enabled (up) or not (down) DisabledReason string `json:"DisabledReason"` // the reason why the peer has been disabled - ExpiresAt *time.Time `json:"column:expires_at"` // expiry dates for peers - Notes string `json:"notes"` // a note field for peers + ExpiresAt *time.Time `json:"ExpiresAt"` // expiry dates for peers + Notes string `json:"Notes"` // a note field for peers Endpoint StringConfigOption `json:"Endpoint"` // the endpoint address - EndpointPublicKey string `json:"EndpointPublicKey"` // the endpoint public key + EndpointPublicKey StringConfigOption `json:"EndpointPublicKey"` // the endpoint public key AllowedIPs StringSliceConfigOption `json:"AllowedIPs"` // all allowed ip subnets, comma seperated ExtraAllowedIPs []string `json:"ExtraAllowedIPs"` // all allowed ip subnets on the server side, comma seperated PresharedKey string `json:"PresharedKey"` // the pre-shared Key of the peer @@ -53,7 +53,7 @@ func NewPeer(src *domain.Peer) *Peer { ExpiresAt: src.ExpiresAt, Notes: src.Notes, Endpoint: StringConfigOptionFromDomain(src.Endpoint), - EndpointPublicKey: src.EndpointPublicKey, + EndpointPublicKey: StringConfigOptionFromDomain(src.EndpointPublicKey), AllowedIPs: StringSliceConfigOptionFromDomain(src.AllowedIPsStr), ExtraAllowedIPs: internal.SliceString(src.ExtraAllowedIPsStr), PresharedKey: string(src.PresharedKey), @@ -92,7 +92,7 @@ func NewDomainPeer(src *Peer) *domain.Peer { res := &domain.Peer{ BaseModel: domain.BaseModel{}, Endpoint: StringConfigOptionToDomain(src.Endpoint), - EndpointPublicKey: src.EndpointPublicKey, + EndpointPublicKey: StringConfigOptionToDomain(src.EndpointPublicKey), AllowedIPsStr: StringSliceConfigOptionToDomain(src.AllowedIPs), ExtraAllowedIPsStr: internal.SliceToString(src.ExtraAllowedIPs), PresharedKey: domain.PreSharedKey(src.PresharedKey), diff --git a/internal/app/migrate_v1.go b/internal/app/migrate_v1.go index d5308c7..ccb3483 100644 --- a/internal/app/migrate_v1.go +++ b/internal/app/migrate_v1.go @@ -320,7 +320,9 @@ func migrateV1Peers(oldDb, newDb *gorm.DB) error { Endpoint: domain.StringConfigOption{ Value: oldPeer.Endpoint, Overridable: !oldPeer.IgnoreGlobalSettings, }, - EndpointPublicKey: iface.PublicKey, + EndpointPublicKey: domain.StringConfigOption{ + Value: iface.PublicKey, Overridable: !oldPeer.IgnoreGlobalSettings, + }, AllowedIPsStr: domain.StringConfigOption{ Value: oldPeer.AllowedIPsStr, Overridable: !oldPeer.IgnoreGlobalSettings, }, @@ -333,7 +335,6 @@ func migrateV1Peers(oldDb, newDb *gorm.DB) error { Identifier: domain.PeerIdentifier(oldPeer.PublicKey), UserIdentifier: user.Identifier, InterfaceIdentifier: iface.Identifier, - Temporary: nil, Disabled: disableTime, DisabledReason: disableReason, ExpiresAt: expiryTime, diff --git a/internal/app/repos.go b/internal/app/repos.go index 22d0876..d8b8aee 100644 --- a/internal/app/repos.go +++ b/internal/app/repos.go @@ -38,8 +38,10 @@ type WireGuardManager interface { UpdateInterface(ctx context.Context, in *domain.Interface) (*domain.Interface, error) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error PreparePeer(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Peer, error) - DeletePeer(ctx context.Context, id domain.PeerIdentifier) error GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) + CreatePeer(ctx context.Context, p *domain.Peer) (*domain.Peer, error) + UpdatePeer(ctx context.Context, p *domain.Peer) (*domain.Peer, error) + DeletePeer(ctx context.Context, id domain.PeerIdentifier) error } type StatisticsCollector interface { diff --git a/internal/app/wireguard/repos.go b/internal/app/wireguard/repos.go index 15a0b1e..7d6ffd8 100644 --- a/internal/app/wireguard/repos.go +++ b/internal/app/wireguard/repos.go @@ -20,6 +20,7 @@ type InterfaceAndPeerDatabaseRepo interface { SavePeer(ctx context.Context, id domain.PeerIdentifier, updateFunc func(in *domain.Peer) (*domain.Peer, error)) error DeletePeer(ctx context.Context, id domain.PeerIdentifier) error GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) + GetUsedIpsPerSubnet(ctx context.Context) (map[domain.Cidr][]domain.Cidr, error) } type StatisticsDatabaseRepo interface { diff --git a/internal/app/wireguard/wireguard.go b/internal/app/wireguard/wireguard.go index 5efb484..9ce46fe 100644 --- a/internal/app/wireguard/wireguard.go +++ b/internal/app/wireguard/wireguard.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "github.com/google/uuid" "github.com/h44z/wg-portal/internal/app" "time" @@ -154,7 +153,7 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain } peer.InterfaceIdentifier = in.Identifier - peer.EndpointPublicKey = in.PublicKey + peer.EndpointPublicKey = domain.StringConfigOption{Value: in.PublicKey, Overridable: true} peer.AllowedIPsStr = domain.StringConfigOption{Value: in.PeerDefAllowedIPsStr, Overridable: true} peer.Interface.Addresses = p.AllowedIPs // use allowed IP's as the peer IP's peer.Interface.DnsStr = domain.StringConfigOption{Value: in.PeerDefDnsStr, Overridable: true} @@ -298,7 +297,7 @@ func (m Manager) PrepareInterface(ctx context.Context) (*domain.Interface, error return nil, fmt.Errorf("failed to generate new identifier: %w", err) } - ipv4, ipv6, err := m.getFreshIpConfig(ctx) + ipv4, ipv6, err := m.getFreshInterfaceIpConfig(ctx) if err != nil { return nil, fmt.Errorf("failed to generate new ip config: %w", err) } @@ -390,7 +389,7 @@ func (m Manager) getNewInterfaceName(ctx context.Context) (domain.InterfaceIdent return name, nil } -func (m Manager) getFreshIpConfig(ctx context.Context) (ipV4, ipV6 domain.Cidr, err error) { +func (m Manager) getFreshInterfaceIpConfig(ctx context.Context) (ipV4, ipV6 domain.Cidr, err error) { ips, err := m.db.GetInterfaceIps(ctx) if err != nil { err = fmt.Errorf("failed to get existing IP addresses: %w", err) @@ -401,34 +400,49 @@ func (m Manager) getFreshIpConfig(ctx context.Context) (ipV4, ipV6 domain.Cidr, ipV4, _ = domain.CidrFromString(m.cfg.Advanced.StartCidrV4) ipV6, _ = domain.CidrFromString(m.cfg.Advanced.StartCidrV6) + netV4 := ipV4.NetworkAddr() + netV6 := ipV6.NetworkAddr() for { - ipV4Conflict := false - ipV6Conflict := false + v4Conflict := false + v6Conflict := false for _, usedIps := range ips { - for _, ip := range usedIps { - if ipV4 == ip { - ipV4Conflict = true + for _, usedIp := range usedIps { + usedNetwork := usedIp.NetworkAddr() + if netV4 == usedNetwork { + v4Conflict = true } - if ipV6 == ip { - ipV6Conflict = true + if netV6 == usedNetwork { + v6Conflict = true } } } - if !ipV4Conflict && (!useV6 || !ipV6Conflict) { + if !v4Conflict && (!useV6 || !v6Conflict) { break } - if ipV4Conflict { - ipV4 = ipV4.NextSubnet() + if v4Conflict { + netV4 = netV4.NextSubnet() } - if ipV6Conflict && useV6 { - ipV6 = ipV6.NextSubnet() + if v6Conflict && useV6 { + netV6 = netV6.NextSubnet() + } + + if !netV4.IsValid() { + return domain.Cidr{}, domain.Cidr{}, fmt.Errorf("IPv4 space exhausted") + } + + if useV6 && !netV6.IsValid() { + return domain.Cidr{}, domain.Cidr{}, fmt.Errorf("IPv6 space exhausted") } } + // use first address in network for interface + ipV4 = netV4.NextAddr() + ipV6 = netV6.NextAddr() + return } @@ -471,7 +485,7 @@ func (m Manager) CreateInterface(ctx context.Context, in *domain.Interface) (*do return nil, fmt.Errorf("interface %s already exists", in.Identifier) } - if err := m.validateCreation(ctx, existingInterface, in); err != nil { + if err := m.validateInterfaceCreation(ctx, existingInterface, in); err != nil { return nil, fmt.Errorf("creation not allowed: %w", err) } @@ -501,7 +515,7 @@ func (m Manager) UpdateInterface(ctx context.Context, in *domain.Interface) (*do return nil, fmt.Errorf("unable to load existing interface %s: %w", in.Identifier, err) } - if err := m.validateModifications(ctx, existingInterface, in); err != nil { + if err := m.validateInterfaceModifications(ctx, existingInterface, in); err != nil { return nil, fmt.Errorf("update not allowed: %w", err) } @@ -531,7 +545,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif return fmt.Errorf("unable to find interface %s: %w", id, err) } - if err := m.validateDeletion(ctx, existingInterface); err != nil { + if err := m.validateInterfaceDeletion(ctx, existingInterface); err != nil { return fmt.Errorf("deletion not allowed: %w", err) } @@ -553,7 +567,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif return nil } -func (m Manager) validateModifications(ctx context.Context, old, new *domain.Interface) error { +func (m Manager) validateInterfaceModifications(ctx context.Context, old, new *domain.Interface) error { currentUser := domain.GetUserInfo(ctx) if !currentUser.IsAdmin { @@ -563,7 +577,7 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Int return nil } -func (m Manager) validateCreation(ctx context.Context, old, new *domain.Interface) error { +func (m Manager) validateInterfaceCreation(ctx context.Context, old, new *domain.Interface) error { currentUser := domain.GetUserInfo(ctx) if new.Identifier == "" { @@ -577,7 +591,7 @@ func (m Manager) validateCreation(ctx context.Context, old, new *domain.Interfac return nil } -func (m Manager) validateDeletion(ctx context.Context, del *domain.Interface) error { +func (m Manager) validateInterfaceDeletion(ctx context.Context, del *domain.Interface) error { currentUser := domain.GetUserInfo(ctx) if !currentUser.IsAdmin { @@ -615,6 +629,11 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier) return nil, fmt.Errorf("unable to find interface %s: %w", id, err) } + ips, err := m.getFreshPeerIpConfig(ctx, iface) + if err != nil { + return nil, fmt.Errorf("unable to get fresh ip addresses: %w", err) + } + kp, err := domain.NewFreshKeypair() if err != nil { return nil, fmt.Errorf("failed to generate keys: %w", err) @@ -630,7 +649,7 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier) peerMode = domain.InterfaceTypeServer } - peerId := domain.PeerIdentifier(uuid.New().String()) + peerId := domain.PeerIdentifier(kp.PublicKey) freshPeer := &domain.Peer{ BaseModel: domain.BaseModel{ CreatedBy: string(currentUser.Id), @@ -639,7 +658,7 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier) UpdatedAt: time.Now(), }, Endpoint: domain.NewStringConfigOption(iface.PeerDefEndpoint, true), - EndpointPublicKey: iface.PublicKey, + EndpointPublicKey: domain.NewStringConfigOption(iface.PublicKey, true), AllowedIPsStr: domain.NewStringConfigOption(iface.PeerDefAllowedIPsStr, true), ExtraAllowedIPsStr: "", PresharedKey: pk, @@ -655,7 +674,7 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier) Interface: domain.PeerInterfaceConfig{ KeyPair: kp, Type: peerMode, - Addresses: nil, // TODO + Addresses: ips, CheckAliveAddress: "", DnsStr: domain.NewStringConfigOption(iface.PeerDefDnsStr, true), DnsSearchStr: domain.NewStringConfigOption(iface.PeerDefDnsSearchStr, true), @@ -672,6 +691,121 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier) return freshPeer, nil } +func (m Manager) getFreshPeerIpConfig(ctx context.Context, iface *domain.Interface) (ips []domain.Cidr, err error) { + networks, err := domain.CidrsFromString(iface.PeerDefNetworkStr) + if err != nil { + err = fmt.Errorf("failed to parse default network address: %w", err) + return + } + + existingIps, err := m.db.GetUsedIpsPerSubnet(ctx) + if err != nil { + err = fmt.Errorf("failed to get existing IP addresses: %w", err) + return + } + + for _, network := range networks { + ip := network.NextAddr() + + for { + ipConflict := false + for _, usedIp := range existingIps[network] { + if usedIp == ip { + ipConflict = true + } + } + + if !ipConflict { + break + } + + ip = ip.NextAddr() + + if !ip.IsValid() { + return nil, fmt.Errorf("ip space on subnet %s is exhausted", network.String()) + } + } + + ips = append(ips, ip) + } + + return +} + +func (m Manager) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) { + peer, err := m.db.GetPeer(ctx, id) + if err != nil { + return nil, fmt.Errorf("unable to find peer %s: %w", id, err) + } + + return peer, nil +} + +func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Peer, error) { + existingPeer, err := m.db.GetPeer(ctx, peer.Identifier) + if err != nil && !errors.Is(err, domain.ErrNotFound) { + return nil, fmt.Errorf("unable to load existing peer %s: %w", peer.Identifier, err) + } + if existingPeer != nil { + return nil, fmt.Errorf("peer %s already exists", peer.Identifier) + } + + if err := m.validatePeerCreation(ctx, existingPeer, peer); err != nil { + return nil, fmt.Errorf("creation not allowed: %w", err) + } + + err = m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) { + peer.CopyCalculatedAttributes(p) + + err = m.wg.SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier, + func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) { + domain.MergeToPhysicalPeer(pp, peer) + return pp, nil + }) + if err != nil { + return nil, fmt.Errorf("failed to create wireguard peer %s: %w", peer.Identifier, err) + } + + return peer, nil + }) + if err != nil { + return nil, fmt.Errorf("creation failure: %w", err) + } + + return peer, nil +} + +func (m Manager) UpdatePeer(ctx context.Context, peer *domain.Peer) (*domain.Peer, error) { + existingPeer, err := m.db.GetPeer(ctx, peer.Identifier) + if err != nil { + return nil, fmt.Errorf("unable to load existing peer %s: %w", peer.Identifier, err) + } + + if err := m.validatePeerModifications(ctx, existingPeer, peer); err != nil { + return nil, fmt.Errorf("update not allowed: %w", err) + } + + err = m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) { + peer.CopyCalculatedAttributes(p) + + err = m.wg.SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier, + func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) { + domain.MergeToPhysicalPeer(pp, peer) + return pp, nil + }) + if err != nil { + return nil, fmt.Errorf("failed to update wireguard peer %s: %w", peer.Identifier, err) + } + + return peer, nil + }) + if err != nil { + return nil, fmt.Errorf("update failure: %w", err) + } + + return peer, nil +} + func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error { peer, err := m.db.GetPeer(ctx, id) if err != nil { @@ -691,11 +825,36 @@ func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error return nil } -func (m Manager) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) { - peer, err := m.db.GetPeer(ctx, id) - if err != nil { - return nil, fmt.Errorf("unable to find peer %s: %w", id, err) +func (m Manager) validatePeerModifications(ctx context.Context, old, new *domain.Peer) error { + currentUser := domain.GetUserInfo(ctx) + + if !currentUser.IsAdmin { + return fmt.Errorf("insufficient permissions") } - return peer, nil + return nil +} + +func (m Manager) validatePeerCreation(ctx context.Context, old, new *domain.Peer) error { + currentUser := domain.GetUserInfo(ctx) + + if new.Identifier == "" { + return fmt.Errorf("invalid peer identifier") + } + + if !currentUser.IsAdmin { + return fmt.Errorf("insufficient permissions") + } + + return nil +} + +func (m Manager) validatePeerDeletion(ctx context.Context, del *domain.Peer) error { + currentUser := domain.GetUserInfo(ctx) + + if !currentUser.IsAdmin { + return fmt.Errorf("insufficient permissions") + } + + return nil } diff --git a/internal/domain/ip.go b/internal/domain/ip.go index 47b664a..3ec1367 100644 --- a/internal/domain/ip.go +++ b/internal/domain/ip.go @@ -21,6 +21,10 @@ func (c Cidr) String() string { return c.Prefix().String() } +func (c Cidr) IsValid() bool { + return c.Prefix().IsValid() +} + func CidrFromString(str string) (Cidr, error) { prefix, err := netip.ParsePrefix(strings.TrimSpace(str)) if err != nil { @@ -141,16 +145,20 @@ func (c Cidr) NetworkAddr() Cidr { func (c Cidr) NextAddr() Cidr { prefix := c.Prefix() + nextAddr := prefix.Addr().Next() return Cidr{ - Addr: prefix.Addr().Next().String(), + Cidr: netip.PrefixFrom(nextAddr, c.NetLength).String(), + Addr: nextAddr.String(), NetLength: prefix.Bits(), } } func (c Cidr) NextSubnet() Cidr { prefix := c.Prefix() + nextAddr := c.BroadcastAddr().Prefix().Addr().Next() return Cidr{ - Addr: c.BroadcastAddr().Prefix().Addr().Next().String(), + Cidr: netip.PrefixFrom(nextAddr, c.NetLength).String(), + Addr: nextAddr.String(), NetLength: prefix.Bits(), } } diff --git a/internal/domain/peer.go b/internal/domain/peer.go index 52cd70c..f5b3ffd 100644 --- a/internal/domain/peer.go +++ b/internal/domain/peer.go @@ -28,7 +28,7 @@ type Peer struct { // WireGuard specific (for the [peer] section of the config file) Endpoint StringConfigOption `gorm:"embedded;embeddedPrefix:endpoint_"` // the endpoint address - EndpointPublicKey string `gorm:"column:endpoint_pubkey"` // the endpoint public key + EndpointPublicKey StringConfigOption `gorm:"embedded;embeddedPrefix:endpoint_pubkey_"` // the endpoint public key AllowedIPsStr StringConfigOption `gorm:"embedded;embeddedPrefix:allowed_ips_str_"` // all allowed ip subnets, comma seperated ExtraAllowedIPsStr string // all allowed ip subnets on the server side, comma seperated PresharedKey PreSharedKey // the pre-shared Key of the peer @@ -49,11 +49,11 @@ type Peer struct { Interface PeerInterfaceConfig `gorm:"embedded"` } -func (p Peer) IsDisabled() bool { +func (p *Peer) IsDisabled() bool { return p.Disabled != nil } -func (p Peer) CheckAliveAddress() string { +func (p *Peer) CheckAliveAddress() string { if p.Interface.CheckAliveAddress != "" { return p.Interface.CheckAliveAddress } @@ -65,6 +65,10 @@ func (p Peer) CheckAliveAddress() string { return "" } +func (p *Peer) CopyCalculatedAttributes(src *Peer) { + p.BaseModel = src.BaseModel +} + type PeerInterfaceConfig struct { KeyPair // private/public Key of the peer @@ -149,7 +153,7 @@ func (p PhysicalPeer) GetAllowedIPs() ([]net.IPNet, error) { func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer { peer := &Peer{ Endpoint: StringConfigOption{Value: pp.Endpoint, Overridable: true}, - EndpointPublicKey: "", + EndpointPublicKey: StringConfigOption{Value: "", Overridable: true}, AllowedIPsStr: StringConfigOption{Value: "", Overridable: true}, ExtraAllowedIPsStr: "", PresharedKey: pp.PresharedKey,