global peer defaults, many more improvements

This commit is contained in:
Christoph Haas 2023-07-20 23:17:32 +02:00
parent 2235c2a0d7
commit 5ec2ad6827
24 changed files with 446 additions and 85 deletions

View File

@ -9,10 +9,12 @@ import { validateCIDR, validateIP, validateDomain } from '@/helpers/validators';
import isCidr from "is-cidr";
import {isIP} from 'is-ip';
import { freshInterface } from '@/helpers/models';
import {peerStore} from "@/stores/peers";
const { t } = useI18n()
const interfaces = interfaceStore()
const peers = peerStore()
const props = defineProps({
interfaceId: String,
@ -243,6 +245,31 @@ async function save() {
}
}
async function applyPeerDefaults() {
if (props.interfaceId==='#NEW#') {
return; // do nothing for new interfaces
}
try {
await interfaces.ApplyPeerDefaults(selectedInterface.value.Identifier, formData.value)
notify({
title: "Peer Defaults Applied",
text: "Applied current peer defaults to all available peers.",
type: 'success',
})
await peers.LoadPeers(selectedInterface.value.Identifier) // reload all peers after applying the defaults
} catch (e) {
console.log(e)
notify({
title: "Backend Connection Failure",
text: "Failed to apply peer defaults!",
type: 'error',
})
}
}
async function del() {
try {
await interfaces.DeleteInterface(selectedInterface.value.Identifier)
@ -312,11 +339,11 @@ async function del() {
:validate="validateCIDR"
@on-tags-changed="handleChangeAddresses"/>
</div>
<div v-if="formData.Type==='server'" class="form-group">
<div v-if="formData.Mode==='server'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.interfaceedit.listenport') }}</label>
<input v-model="formData.ListenPort" class="form-control" placeholder="Listen Port" type="text">
<input v-model="formData.ListenPort" class="form-control" placeholder="Listen Port" type="number">
</div>
<div class="form-group">
<div v-if="formData.Mode!=='server'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.interfaceedit.dns') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Dns"
placeholder="DNS Servers"
@ -324,7 +351,7 @@ async function del() {
:validate="validateIP"
@on-tags-changed="handleChangeDns"/>
</div>
<div class="form-group">
<div v-if="formData.Mode!=='server'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.interfaceedit.dnssearch') }}</label>
<vue3-tags-input class="form-control" :tags="formData.DnsSearch"
placeholder="DNS Search prefixes"
@ -463,6 +490,10 @@ async function del() {
<textarea v-model="formData.PeerDefPostDown" class="form-control" rows="2"></textarea>
</div>
</fieldset>
<fieldset v-if="props.interfaceId!=='#NEW#'" class="text-end">
<hr class="mt-4">
<button class="btn btn-primary me-1" type="button" @click.prevent="applyPeerDefaults">Apply Peer Defaults</button>
</fieldset>
</div>
</div>
</template>

View File

@ -132,11 +132,43 @@ watch(() => props.visible, async (newValue, oldValue) => {
formData.value.PreDown = selectedPeer.value.PreDown
formData.value.PostDown = selectedPeer.value.PostDown
if (!formData.value.Endpoint.Overridable ||
!formData.value.EndpointPublicKey.Overridable ||
!formData.value.AllowedIPs.Overridable ||
!formData.value.PersistentKeepalive.Overridable ||
!formData.value.Dns.Overridable ||
!formData.value.DnsSearch.Overridable ||
!formData.value.Mtu.Overridable ||
!formData.value.FirewallMark.Overridable ||
!formData.value.RoutingTable.Overridable ||
!formData.value.PreUp.Overridable ||
!formData.value.PostUp.Overridable ||
!formData.value.PreDown.Overridable ||
!formData.value.PostDown.Overridable) {
formData.value.IgnoreGlobalSettings = true
}
}
}
}
)
watch(() => formData.value.IgnoreGlobalSettings, async (newValue, oldValue) => {
formData.value.Endpoint.Overridable = !newValue
formData.value.EndpointPublicKey.Overridable = !newValue
formData.value.AllowedIPs.Overridable = !newValue
formData.value.PersistentKeepalive.Overridable = !newValue
formData.value.Dns.Overridable = !newValue
formData.value.DnsSearch.Overridable = !newValue
formData.value.Mtu.Overridable = !newValue
formData.value.FirewallMark.Overridable = !newValue
formData.value.RoutingTable.Overridable = !newValue
formData.value.PreUp.Overridable = !newValue
formData.value.PostUp.Overridable = !newValue
formData.value.PreDown.Overridable = !newValue
formData.value.PostDown.Overridable = !newValue
}
)
function close() {
formData.value = freshPeer()
emit('close')
@ -346,6 +378,10 @@ async function del() {
<input class="form-check-input" type="checkbox" v-model="formData.Disabled">
<label class="form-check-label" >Disabled</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" v-model="formData.IgnoreGlobalSettings">
<label class="form-check-label">Ignore global settings</label>
</div>
</div>
<div class="form-group col-md-6">
<label class="form-label">{{ $t('modals.peeredit.expiresat') }}</label>

View File

@ -131,8 +131,9 @@ function email() {
<li>Identifier: {{ selectedPeer.PublicKey }}</li>
<li>IP Addresses: <span v-for="ip in selectedPeer.Addresses" :key="ip" class="badge rounded-pill bg-light">{{ ip }}</span></li>
<li>Linked User: {{ selectedPeer.UserIdentifier }}</li>
<li>Notes: {{ selectedPeer.Notes }}</li>
<li>Expires At: {{ selectedPeer.ExpiresAt }}</li>
<li v-if="selectedPeer.Notes">Notes: {{ selectedPeer.Notes }}</li>
<li v-if="selectedPeer.ExpiresAt">Expires At: {{ selectedPeer.ExpiresAt }}</li>
<li v-if="selectedPeer.Disabled">Disabled Reason: {{ selectedPeer.DisabledReason }}</li>
</ul>
</div>
<div class="col-md-4">

View File

@ -115,7 +115,10 @@ export function freshPeer() {
PostDown: {
Value: "",
Overridable: true,
}
},
// Internal value
IgnoreGlobalSettings: false
}
}

View File

@ -7,7 +7,9 @@
"firstname": "Firstname",
"lastname": "Lastname",
"login": "Login",
"lang": "Toggle Language"
"lang": "Toggle Language",
"profile": "My Profile",
"logout": "Logout"
},
"home": {
"h1": "WireGuard® VPN Portal",
@ -49,13 +51,13 @@
"h1": "Interface Administration",
"h2": "Current VPN Peers",
"h2-client": "Current Endpoints",
"tableHeadings": [
"Name",
"User",
"IP's",
"Endpoint",
"Handshake"
],
"tableHeadings": {
"name": "Name",
"user": "User",
"ip": "IP's",
"endpoint": "Endpoint",
"stats": "Status"
},
"noInterface": {
"h1": "No interfaces found...",
"message": "Click the plus button above to create a new WireGuard interface."
@ -79,10 +81,6 @@
"h4": "No peers for the selected interface...",
"message": "Click the plus button above to create a new WireGuard interface."
},
"pagination": {
"size": "Number of Elements",
"all": "All (slow)"
},
"peer": {
"new": "Create new peer",
"edit": "Edit peer"
@ -116,9 +114,24 @@
"addUser": "Add User",
"addMulti": "Add Multiple Users"
},
"profile": {
"h2-clients": "My VPN Peers",
"tableHeadings": {
"name": "Name",
"ip": "IP's",
"stats": "Status",
"interface": "Server Interface"
}
},
"modals": {
"peeredit": {
"privatekey": "Private Key"
}
},
"general": {
"pagination": {
"size": "Number of Elements",
"all": "All (slow)"
}
}
}

View File

@ -32,4 +32,15 @@ app.use(createPinia());
app.use(router);
app.use(Notifications);
app.config.globalProperties.$filters = {
truncate(value, maxLength, suffix) {
suffix = suffix || '...'
if (value.length > maxLength) {
return value.substring(0, maxLength) + suffix;
} else {
return value;
}
}
}
app.mount("#app");

View File

@ -123,6 +123,30 @@ export const interfaceStore = defineStore({
console.log(error)
throw new Error(error)
})
},
async ApplyPeerDefaults(id, formData) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/apply-peer-defaults`, formData)
.then(() => {
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async SaveConfiguration(id) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/save-config`)
.then(() => {
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
}
}
})

View File

@ -43,7 +43,7 @@ export const peerStore = defineStore({
return state.Filtered.slice(state.pageOffset, state.pageOffset + state.pageSize)
},
ConfigQrUrl: (state) => {
return (id) => apiWrapper.url(`${baseUrl}/config-qr/${base64_url_encode(id)}`)
return (id) => state.peers.find((p) => p.Identifier === id) ? apiWrapper.url(`${baseUrl}/config-qr/${base64_url_encode(id)}`) : ''
},
isFetching: (state) => state.fetching,
hasNextPage: (state) => state.pageOffset < (state.FilteredCount - state.pageSize),

View File

@ -3,7 +3,7 @@ import {apiWrapper} from "@/helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification";
import {authStore} from "@/stores/auth";
import { base64_url_encode } from '@/helpers/encoding';
import {freshStats} from "@/helpers/models";
const baseUrl = `/user`
@ -11,6 +11,8 @@ export const profileStore = defineStore({
id: 'profile',
state: () => ({
peers: [],
stats: {},
statsEnabled: false,
user: {},
filter: "",
pageSize: 10,
@ -40,6 +42,10 @@ export const profileStore = defineStore({
hasNextPage: (state) => state.pageOffset < (state.FilteredPeerCount - state.pageSize),
hasPrevPage: (state) => state.pageOffset > 0,
currentPage: (state) => (state.pageOffset / state.pageSize)+1,
Statistics: (state) => {
return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats()
},
hasStatistics: (state) => state.statsEnabled,
},
actions: {
afterPageSizeChange() {
@ -77,6 +83,14 @@ export const profileStore = defineStore({
this.user = user
this.fetching = false
},
setStats(statsResponse) {
if (!statsResponse) {
this.stats = {}
this.statsEnabled = false
}
this.stats = statsResponse.Stats
this.statsEnabled = statsResponse.Enabled
},
async LoadPeers() {
this.fetching = true
let currentUser = authStore().user.Identifier
@ -91,6 +105,20 @@ export const profileStore = defineStore({
})
})
},
async LoadStats() {
this.fetching = true
let currentUser = authStore().user.Identifier
return apiWrapper.get(`${baseUrl}/${base64_url_encode(currentUser)}/stats`)
.then(this.setStats)
.catch(error => {
this.setStats(undefined)
console.log("Failed to load peer stats: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load peer stats!",
})
})
},
async LoadUser() {
this.fetching = true
let currentUser = authStore().user.Identifier

View File

@ -8,6 +8,7 @@ import InterfaceViewModal from "../components/InterfaceViewModal.vue";
import {onMounted, ref} from "vue";
import {peerStore} from "../stores/peers";
import {interfaceStore} from "../stores/interfaces";
import {notify} from "@kyvg/vue3-notification";
const interfaces = interfaceStore()
const peers = peerStore()
@ -44,6 +45,25 @@ async function download() {
document.body.removeChild(element)
}
async function saveConfig() {
try {
await interfaces.SaveConfiguration(interfaces.GetSelected.Identifier)
notify({
title: "Interface configuration persisted to file",
text: "The interface configuration has been written to the wg-quick configuration file.",
type: 'success',
})
} catch (e) {
console.log(e)
notify({
title: "Backend Connection Failure",
text: "Failed to persist interface configuration file!",
type: 'error',
})
}
}
onMounted(async () => {
await interfaces.LoadInterfaces()
await peers.LoadPeers(undefined) // use default interface
@ -99,11 +119,12 @@ onMounted(async () => {
<div class="row">
<div class="col-12 col-lg-8">
{{ $t('interfaces.statusBox.h1') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{interfaces.GetSelected.Mode}} {{ $t('interfaces.statusBox.mode') }})
<span v-if="interfaces.GetSelected.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="interfaces.GetSelected.DisabledReason"></i></span>
</div>
<div class="col-12 col-lg-4 text-lg-end">
<a class="btn-link" href="#" title="Show interface configuration" @click.prevent="viewedInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-eye"></i></a>
<a class="ms-5 btn-link" href="#" title="Download interface configuration" @click.prevent="download"><i class="fas fa-download"></i></a>
<a class="ms-5 btn-link" href="#" title="Write interface configuration file"><i class="fas fa-save"></i></a>
<a class="ms-5 btn-link" href="#" title="Write interface configuration file" @click.prevent="saveConfig"><i class="fas fa-save"></i></a>
<a class="ms-5 btn-link" href="#" title="Edit interface settings" @click.prevent="editInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-cog"></i></a>
</div>
</div>
@ -152,7 +173,7 @@ onMounted(async () => {
<td>{{interfaces.GetSelected.Mtu}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.statusBox.intervall') }}:</td>
<td>{{ $t('interfaces.statusBox.interval') }}:</td>
<td>{{interfaces.GetSelected.PeerDefPersistentKeepalive}}</td>
</tr>
<tr>
@ -248,7 +269,7 @@ onMounted(async () => {
<td>{{interfaces.GetSelected.Mtu}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.statusBox.intervall') }}:</td>
<td>{{ $t('interfaces.statusBox.interval') }}:</td>
<td>{{interfaces.GetSelected.PeerDefPersistentKeepalive}}</td>
</tr>
</tbody>
@ -291,11 +312,12 @@ onMounted(async () => {
<th scope="col">
<input id="flexCheckDefault" class="form-check-input" title="Select all" type="checkbox" value="">
</th><!-- select -->
<th scope="col">{{ $t('interfaces.tableHeadings[0]') }}</th>
<th scope="col">{{ $t('interfaces.tableHeadings[1]') }}</th>
<th scope="col">{{ $t('interfaces.tableHeadings[2]') }}</th>
<th v-if="interfaces.GetSelected.Mode==='client'" scope="col">{{ $t('interfaces.tableHeadings[3]') }}</th>
<th v-if="peers.hasStatistics" scope="col">{{ $t('interfaces.tableHeadings[4]') }}</th>
<th scope="col"></th><!-- status -->
<th scope="col">{{ $t('interfaces.tableHeadings.name') }}</th>
<th scope="col">{{ $t('interfaces.tableHeadings.user') }}</th>
<th scope="col">{{ $t('interfaces.tableHeadings.ip') }}</th>
<th v-if="interfaces.GetSelected.Mode==='client'" scope="col">{{ $t('interfaces.tableHeadings.endpoint') }}</th>
<th v-if="peers.hasStatistics" scope="col">{{ $t('interfaces.tableHeadings.stats') }}</th>
<th scope="col"></th><!-- Actions -->
</tr>
</thead>
@ -304,7 +326,11 @@ onMounted(async () => {
<th scope="row">
<input id="flexCheckDefault" class="form-check-input" type="checkbox" value="">
</th>
<td>{{peer.DisplayName}}</td>
<td class="text-center">
<span v-if="peer.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="peer.DisabledReason"></i></span>
<span v-if="!peer.Disabled && peer.ExpiresAt" class="text-warning"><i class="fas fa-hourglass-end expiring-peer" :title="peer.ExpiresAt"></i></span>
</td>
<td><span v-if="peer.DisplayName" :title="peer.Identifier">{{peer.DisplayName}}</span><span v-else :title="peer.Identifier">{{ $filters.truncate(peer.Identifier, 10)}}</span></td>
<td>{{peer.UserIdentifier}}</td>
<td>
<span v-for="ip in peer.Addresses" :key="ip" class="badge bg-light me-1">{{ ip }}</span>
@ -346,14 +372,14 @@ onMounted(async () => {
</div>
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('interfaces.pagination.size') }}:</label>
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select v-model.number="peers.pageSize" class="form-select" @click="peers.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="999999999">{{ $t('interfaces.pagination.all') }}</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option>
</select>
</div>
</div>

View File

@ -12,6 +12,7 @@ const editPeerId = ref("")
onMounted(async () => {
await profile.LoadUser()
await profile.LoadPeers()
await profile.LoadStats()
})
</script>
@ -46,11 +47,11 @@ onMounted(async () => {
<th scope="col">
<input id="flexCheckDefault" class="form-check-input" title="Select all" type="checkbox" value="">
</th><!-- select -->
<th scope="col">{{ $t('profile.tableHeadings[0]') }}</th>
<th scope="col">{{ $t('profile.tableHeadings[1]') }}</th>
<th scope="col">{{ $t('profile.tableHeadings[2]') }}</th>
<th scope="col">{{ $t('profile.tableHeadings[3]') }}</th>
<th scope="col">{{ $t('profile.tableHeadings[4]') }}</th>
<th scope="col"></th><!-- status -->
<th scope="col">{{ $t('profile.tableHeadings.name') }}</th>
<th scope="col">{{ $t('profile.tableHeadings.ip') }}</th>
<th v-if="profile.hasStatistics" scope="col">{{ $t('profile.tableHeadings.stats') }}</th>
<th scope="col">{{ $t('profile.tableHeadings.interface') }}</th>
<th scope="col"></th><!-- Actions -->
</tr>
</thead>
@ -59,13 +60,23 @@ onMounted(async () => {
<th scope="row">
<input id="flexCheckDefault" class="form-check-input" type="checkbox" value="">
</th>
<td>{{peer.DisplayName}}</td>
<td>{{peer.Identifier}}</td>
<td>{{peer.UserIdentifier}}</td>
<td class="text-center">
<span v-if="peer.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="peer.DisabledReason"></i></span>
<span v-if="!peer.Disabled && peer.ExpiresAt" class="text-warning"><i class="fas fa-hourglass-end" :title="peer.ExpiresAt"></i></span>
</td>
<td><span v-if="peer.DisplayName" :title="peer.Identifier">{{peer.DisplayName}}</span><span v-else :title="peer.Identifier">{{$filters.truncate(peer.Identifier, 10)}}</span></td>
<td>
<span v-for="ip in peer.Addresses" :key="ip" class="badge rounded-pill bg-light">{{ ip }}</span>
</td>
<td>{{peer.LastConnected}}</td>
<td v-if="profile.hasStatistics">
<div v-if="profile.Statistics(peer.Identifier).IsConnected">
<span class="badge rounded-pill bg-success"><i class="fa-solid fa-link"></i></span> <span :title="peers.Statistics(peer.Identifier).LastHandshake">Connected</span>
</div>
<div v-else>
<span class="badge rounded-pill bg-light"><i class="fa-solid fa-link-slash"></i></span>
</div>
</td>
<td>{{peer.InterfaceIdentifier}}</td>
<td class="text-center">
<a href="#" title="Show peer" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a>
<a href="#" title="Edit peer" @click.prevent="editPeerId=peer.Identifier"><i class="fas fa-cog"></i></a>
@ -94,14 +105,14 @@ onMounted(async () => {
</div>
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('profile.pagination.size') }}:</label>
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select v-model.number="profile.pageSize" class="form-select" @click="profile.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="999999999">{{ $t('profile.pagination.all') }}</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option>
</select>
</div>
</div>

View File

@ -116,14 +116,14 @@ function editUser(user) {
</div>
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('interfaces.pagination.size') }}:</label>
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select v-model.number="users.pageSize" class="form-select" @click="users.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="999999999">{{ $t('interfaces.pagination.all') }}</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option>
</select>
</div>
</div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -11,7 +11,7 @@
let WGPORTAL_BACKEND_BASE_URL="http://localhost:5000/api/v0";
</script>
<script src="/api/v0/config/frontend.js"></script>
<script type="module" crossorigin src="/app/assets/index-6aef20f0.js"></script>
<script type="module" crossorigin src="/app/assets/index-01f1dd15.js"></script>
<link rel="stylesheet" href="/app/assets/index-a233ff7e.css">
</head>
<body class="d-flex flex-column min-vh-100">

View File

@ -28,6 +28,8 @@ func (e interfaceEndpoint) RegisterRoutes(g *gin.RouterGroup, authenticator *aut
apiGroup.DELETE("/:id", e.handleDelete())
apiGroup.POST("/new", e.handleCreatePost())
apiGroup.GET("/config/:id", e.handleConfigGet())
apiGroup.POST("/:id/save-config", e.handleSaveConfigPost())
apiGroup.POST("/:id/apply-peer-defaults", e.handleApplyPeerDefaultsPost())
apiGroup.GET("/peers/:id", e.handlePeersGet())
}
@ -295,3 +297,76 @@ func (e interfaceEndpoint) handleDelete() gin.HandlerFunc {
c.Status(http.StatusNoContent)
}
}
// handleSaveConfigPost returns a gorm handler function.
//
// @ID interfaces_handleSaveConfigPost
// @Tags Interface
// @Summary Save the interface configuration in wg-quick format to a file.
// @Produce json
// @Param id path string true "The interface identifier"
// @Success 204 "No content if saving the configuration was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /interface/{id}/save-config [post]
func (e interfaceEndpoint) handleSaveConfigPost() gin.HandlerFunc {
return func(c *gin.Context) {
//ctx := domain.SetUserInfoFromGin(c)
id := Base64UrlDecode(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
return
}
// TODO: implement
c.Status(http.StatusNoContent)
}
}
// handleApplyPeerDefaultsPost returns a gorm handler function.
//
// @ID interfaces_handleApplyPeerDefaultsPost
// @Tags Interface
// @Summary Apply all peer defaults to the available peers.
// @Produce json
// @Param id path string true "The interface identifier"
// @Param request body model.Interface true "The interface data"
// @Success 204 "No content if applying peer defaults was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /interface/{id}/apply-peer-defaults [post]
func (e interfaceEndpoint) handleApplyPeerDefaultsPost() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := Base64UrlDecode(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
return
}
var in model.Interface
err := c.BindJSON(&in)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
if id != in.Identifier {
c.JSON(http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "interface id mismatch"})
return
}
err = e.app.ApplyPeerDefaults(ctx, model.NewDomainInterface(&in))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{
Code: http.StatusInternalServerError, Message: err.Error(),
})
return
}
c.Status(http.StatusNoContent)
}
}

View File

@ -433,6 +433,6 @@ func (e peerEndpoint) handleStatsGet() gin.HandlerFunc {
return
}
c.JSON(http.StatusOK, model.NewPeerStats(true, stats))
c.JSON(http.StatusOK, model.NewPeerStats(e.app.Config.Statistics.CollectPeerData, stats))
}
}

View File

@ -26,6 +26,7 @@ func (e userEndpoint) RegisterRoutes(g *gin.RouterGroup, authenticator *authenti
apiGroup.DELETE("/:id", e.handleDelete())
apiGroup.POST("/new", e.handleCreatePost())
apiGroup.GET("/:id/peers", e.handlePeersGet())
apiGroup.GET("/:id/stats", e.handleStatsGet())
}
// handleAllGet returns a gorm handler function.
@ -164,6 +165,7 @@ func (e userEndpoint) handleCreatePost() gin.HandlerFunc {
// @Summary Get peers for the given user.
// @Produce json
// @Success 200 {object} []model.Peer
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/{id}/peers [get]
func (e userEndpoint) handlePeersGet() gin.HandlerFunc {
@ -186,6 +188,36 @@ func (e userEndpoint) handlePeersGet() gin.HandlerFunc {
}
}
// handleStatsGet returns a gorm handler function.
//
// @ID users_handleStatsGet
// @Tags Users
// @Summary Get peer stats for the given user.
// @Produce json
// @Success 200 {object} model.PeerStats
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/{id}/stats [get]
func (e userEndpoint) handleStatsGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
userId := Base64UrlDecode(c.Param("id"))
if userId == "" {
c.JSON(http.StatusBadRequest, model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
return
}
stats, err := e.app.GetUserPeerStats(ctx, domain.UserIdentifier(userId))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
c.JSON(http.StatusOK, model.NewPeerStats(e.app.Config.Statistics.CollectPeerData, stats))
}
}
// handleDelete returns a gorm handler function.
//
// @ID users_handleDelete

View File

@ -1,6 +1,7 @@
package configfile
import (
"bufio"
"bytes"
"context"
"fmt"
@ -8,6 +9,7 @@ import (
"github.com/h44z/wg-portal/internal/domain"
"github.com/yeqown/go-qrcode/v2"
"io"
"strings"
)
type Manager struct {
@ -64,12 +66,21 @@ func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifi
return nil, fmt.Errorf("failed to get peer config for %s: %w", id, err)
}
configBytes, err := io.ReadAll(cfgData)
if err != nil {
// remove comments from qr-code config as it is not needed
sb := strings.Builder{}
scanner := bufio.NewScanner(cfgData)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "#") {
sb.WriteString(line)
sb.WriteString("\n")
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read peer config for %s: %w", id, err)
}
code, err := qrcode.New(string(configBytes))
code, err := qrcode.New(sb.String())
if err != nil {
return nil, fmt.Errorf("failed to initializeqr code for %s: %w", id, err)
}

View File

@ -32,6 +32,7 @@ type WireGuardManager interface {
CreateDefaultPeer(ctx context.Context, user *domain.User) error
GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error)
GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error)
GetUserPeerStats(ctx context.Context, id domain.UserIdentifier) ([]domain.PeerStatus, error)
GetAllInterfaces(ctx context.Context) ([]domain.Interface, error)
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
PrepareInterface(ctx context.Context) (*domain.Interface, error)
@ -44,6 +45,7 @@ type WireGuardManager interface {
CreateMultiplePeers(ctx context.Context, id domain.InterfaceIdentifier, r *domain.PeerCreationRequest) ([]domain.Peer, error)
UpdatePeer(ctx context.Context, p *domain.Peer) (*domain.Peer, error)
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error
}
type StatisticsCollector interface {

View File

@ -899,3 +899,44 @@ func (m Manager) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier
return m.db.GetPeersStats(ctx, peerIds...)
}
func (m Manager) GetUserPeerStats(ctx context.Context, id domain.UserIdentifier) ([]domain.PeerStatus, error) {
peers, err := m.db.GetUserPeers(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to fetch peers for user %s: %w", id, err)
}
peerIds := make([]domain.PeerIdentifier, len(peers))
for i, peer := range peers {
peerIds[i] = peer.Identifier
}
return m.db.GetPeersStats(ctx, peerIds...)
}
func (m Manager) ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error {
existingInterface, err := m.db.GetInterface(ctx, in.Identifier)
if err != nil {
return fmt.Errorf("unable to load existing interface %s: %w", in.Identifier, err)
}
if err := m.validateInterfaceModifications(ctx, existingInterface, in); err != nil {
return fmt.Errorf("update not allowed: %w", err)
}
peers, err := m.db.GetInterfacePeers(ctx, in.Identifier)
if err != nil {
return fmt.Errorf("failed to find peers for interface %s: %w", in.Identifier, err)
}
for i := range peers {
(&peers[i]).ApplyInterfaceDefaults(in)
_, err := m.UpdatePeer(ctx, &peers[i])
if err != nil {
return fmt.Errorf("failed to apply interface defaults to peer %s: %w", peers[i].Identifier, err)
}
}
return nil
}

View File

@ -92,6 +92,22 @@ func (p *Peer) GetConfigFileName() string {
return filename
}
func (p *Peer) ApplyInterfaceDefaults(in *Interface) {
p.Endpoint.TrySetValue(in.PeerDefEndpoint)
p.EndpointPublicKey.TrySetValue(in.PublicKey)
p.AllowedIPsStr.TrySetValue(in.PeerDefAllowedIPsStr)
p.PersistentKeepalive.TrySetValue(in.PeerDefPersistentKeepalive)
p.Interface.DnsStr.TrySetValue(in.PeerDefDnsStr)
p.Interface.DnsSearchStr.TrySetValue(in.PeerDefDnsSearchStr)
p.Interface.Mtu.TrySetValue(in.PeerDefMtu)
p.Interface.FirewallMark.TrySetValue(in.PeerDefFirewallMark)
p.Interface.RoutingTable.TrySetValue(in.PeerDefRoutingTable)
p.Interface.PreUp.TrySetValue(in.PeerDefPreUp)
p.Interface.PostUp.TrySetValue(in.PeerDefPostUp)
p.Interface.PreDown.TrySetValue(in.PeerDefPreDown)
p.Interface.PostDown.TrySetValue(in.PeerDefPostDown)
}
type PeerInterfaceConfig struct {
KeyPair // private/public Key of the peer