Compare commits

..

2 Commits

Author SHA1 Message Date
Christoph Haas
79eaedb9ca only override isAdmin flag if it is provided by the authentication source 2026-01-19 23:17:56 +01:00
Christoph Haas
70832bfb52 feat: allow multiple auth sources per user (#500,#477) 2026-01-19 23:00:03 +01:00
21 changed files with 25 additions and 431 deletions

View File

@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request' }} if: ${{ github.event_name == 'pull_request' }}
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -35,7 +35,7 @@ jobs:
# ct lint requires Python 3.x to run following packages: # ct lint requires Python 3.x to run following packages:
# - yamale (https://github.com/23andMe/Yamale) # - yamale (https://github.com/23andMe/Yamale)
# - yamllint (https://github.com/adrienverge/yamllint) # - yamllint (https://github.com/adrienverge/yamllint)
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with: with:
python-version: '3.x' python-version: '3.x'
@@ -60,7 +60,7 @@ jobs:
permissions: permissions:
packages: write packages: write
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0

View File

@@ -15,11 +15,11 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with: with:
python-version: 3.x python-version: 3.x

View File

@@ -20,7 +20,7 @@ RUN npm run build
###### ######
# Build backend # Build backend
###### ######
FROM --platform=${BUILDPLATFORM} golang:1.26-alpine AS builder FROM --platform=${BUILDPLATFORM} golang:1.25-alpine AS builder
# Set the working directory # Set the working directory
WORKDIR /build WORKDIR /build
# Download dependencies # Download dependencies

View File

@@ -135,7 +135,6 @@ func main() {
apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers) apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers)
apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard) apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard)
apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth) apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth)
apiV0EndpointWebsocket := handlersV0.NewWebsocketEndpoint(cfg, apiV0Auth, eventBus)
apiFrontend := handlersV0.NewRestApi(apiV0Session, apiFrontend := handlersV0.NewRestApi(apiV0Session,
apiV0EndpointAuth, apiV0EndpointAuth,
@@ -145,7 +144,6 @@ func main() {
apiV0EndpointPeers, apiV0EndpointPeers,
apiV0EndpointConfig, apiV0EndpointConfig,
apiV0EndpointTest, apiV0EndpointTest,
apiV0EndpointWebsocket,
) )
// endregion API v0 (SPA frontend) // endregion API v0 (SPA frontend)

View File

@@ -1,86 +0,0 @@
import { peerStore } from '@/stores/peers';
import { interfaceStore } from '@/stores/interfaces';
import { authStore } from '@/stores/auth';
let socket = null;
let reconnectTimer = null;
let failureCount = 0;
export const websocketWrapper = {
connect() {
if (socket) {
console.log('WebSocket already connected, re-using existing connection.');
return;
}
const protocol = WGPORTAL_BACKEND_BASE_URL.startsWith('https://') ? 'wss://' : 'ws://';
const baseUrl = WGPORTAL_BACKEND_BASE_URL.replace(/^https?:\/\//, '');
const url = `${protocol}${baseUrl}/ws`;
socket = new WebSocket(url);
socket.onopen = () => {
console.log('WebSocket connected');
failureCount = 0;
if (reconnectTimer) {
clearInterval(reconnectTimer);
reconnectTimer = null;
}
};
socket.onclose = () => {
console.log('WebSocket disconnected');
failureCount++;
socket = null;
this.scheduleReconnect();
};
socket.onerror = (error) => {
console.error('WebSocket error:', error);
failureCount++;
socket.close();
socket = null;
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'peer_stats':
peerStore().updatePeerTrafficStats(message.data);
break;
case 'interface_stats':
interfaceStore().updateInterfaceTrafficStats(message.data);
break;
}
};
},
disconnect() {
if (socket) {
socket.close();
socket = null;
}
if (reconnectTimer) {
clearInterval(reconnectTimer);
reconnectTimer = null;
failureCount = 0;
}
},
scheduleReconnect() {
if (reconnectTimer) return;
if (!authStore().IsAuthenticated) return; // Don't reconnect if not logged in
reconnectTimer = setInterval(() => {
if (failureCount > 2) {
console.log('WebSocket connection unavailable, giving up.');
clearInterval(reconnectTimer);
reconnectTimer = null;
return;
}
console.log('Attempting to reconnect WebSocket...');
this.connect();
}, 5000);
}
};

View File

@@ -2,7 +2,6 @@ import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification"; import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '@/helpers/fetch-wrapper' import { apiWrapper } from '@/helpers/fetch-wrapper'
import { websocketWrapper } from '@/helpers/websocket-wrapper'
import router from '../router' import router from '../router'
import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser'; import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser';
import {base64_url_encode} from "@/helpers/encoding"; import {base64_url_encode} from "@/helpers/encoding";
@@ -296,11 +295,9 @@ export const authStore = defineStore('auth',{
} }
} }
localStorage.setItem('user', JSON.stringify(this.user)) localStorage.setItem('user', JSON.stringify(this.user))
websocketWrapper.connect()
} else { } else {
this.user = null this.user = null
localStorage.removeItem('user') localStorage.removeItem('user')
websocketWrapper.disconnect()
} }
}, },
setWebAuthnCredentials(credentials) { setWebAuthnCredentials(credentials) {

View File

@@ -14,7 +14,6 @@ export const interfaceStore = defineStore('interfaces', {
configuration: "", configuration: "",
selected: "", selected: "",
fetching: false, fetching: false,
trafficStats: {},
}), }),
getters: { getters: {
Count: (state) => state.interfaces.length, Count: (state) => state.interfaces.length,
@@ -25,9 +24,6 @@ export const interfaceStore = defineStore('interfaces', {
}, },
GetSelected: (state) => state.interfaces.find((i) => i.Identifier === state.selected) || state.interfaces[0], GetSelected: (state) => state.interfaces.find((i) => i.Identifier === state.selected) || state.interfaces[0],
isFetching: (state) => state.fetching, isFetching: (state) => state.fetching,
TrafficStats: (state) => {
return (state.selected in state.trafficStats) ? state.trafficStats[state.selected] : { Received: 0, Transmitted: 0 }
},
}, },
actions: { actions: {
setInterfaces(interfaces) { setInterfaces(interfaces) {
@@ -38,14 +34,6 @@ export const interfaceStore = defineStore('interfaces', {
this.selected = "" this.selected = ""
} }
this.fetching = false this.fetching = false
this.trafficStats = {}
},
updateInterfaceTrafficStats(interfaceStats) {
const id = interfaceStats.EntityId;
this.trafficStats[id] = {
Received: interfaceStats.BytesReceived,
Transmitted: interfaceStats.BytesTransmitted,
};
}, },
async LoadInterfaces() { async LoadInterfaces() {
this.fetching = true this.fetching = true

View File

@@ -23,7 +23,6 @@ export const peerStore = defineStore('peers', {
fetching: false, fetching: false,
sortKey: 'IsConnected', // Default sort key sortKey: 'IsConnected', // Default sort key
sortOrder: -1, // 1 for ascending, -1 for descending sortOrder: -1, // 1 for ascending, -1 for descending
trafficStats: {},
}), }),
getters: { getters: {
Find: (state) => { Find: (state) => {
@@ -77,9 +76,6 @@ export const peerStore = defineStore('peers', {
Statistics: (state) => { Statistics: (state) => {
return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats() return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats()
}, },
TrafficStats: (state) => {
return (id) => (id in state.trafficStats) ? state.trafficStats[id] : { Received: 0, Transmitted: 0 }
},
hasStatistics: (state) => state.statsEnabled, hasStatistics: (state) => state.statsEnabled,
}, },
@@ -115,7 +111,6 @@ export const peerStore = defineStore('peers', {
this.peers = peers this.peers = peers
this.calculatePages() this.calculatePages()
this.fetching = false this.fetching = false
this.trafficStats = {}
}, },
setPeer(peer) { setPeer(peer) {
this.peer = peer this.peer = peer
@@ -131,19 +126,11 @@ export const peerStore = defineStore('peers', {
if (!statsResponse) { if (!statsResponse) {
this.stats = {} this.stats = {}
this.statsEnabled = false this.statsEnabled = false
this.trafficStats = {}
} else { } else {
this.stats = statsResponse.Stats this.stats = statsResponse.Stats
this.statsEnabled = statsResponse.Enabled this.statsEnabled = statsResponse.Enabled
} }
}, },
updatePeerTrafficStats(peerStats) {
const id = peerStats.EntityId;
this.trafficStats[id] = {
Received: peerStats.BytesReceived,
Transmitted: peerStats.BytesTransmitted,
};
},
async Reset() { async Reset() {
this.setPeers([]) this.setPeers([])
this.setStats(undefined) this.setStats(undefined)

View File

@@ -210,12 +210,6 @@ onMounted(async () => {
<div class="col-12 col-lg-8"> <div class="col-12 col-lg-8">
{{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{ $t('modals.interface-edit.mode.' + interfaces.GetSelected.Mode )}} | {{ $t('interfaces.interface.backend') + ": " + calculateBackendName }}<span v-if="!isBackendValid" :title="t('interfaces.interface.wrong-backend')" class="ms-1 me-1"><i class="fa-solid fa-triangle-exclamation"></i></span>) {{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{ $t('modals.interface-edit.mode.' + interfaces.GetSelected.Mode )}} | {{ $t('interfaces.interface.backend') + ": " + calculateBackendName }}<span v-if="!isBackendValid" :title="t('interfaces.interface.wrong-backend')" class="ms-1 me-1"><i class="fa-solid fa-triangle-exclamation"></i></span>)
<span v-if="interfaces.GetSelected.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="interfaces.GetSelected.DisabledReason"></i></span> <span v-if="interfaces.GetSelected.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="interfaces.GetSelected.DisabledReason"></i></span>
<div v-if="interfaces.GetSelected && (interfaces.TrafficStats.Received > 0 || interfaces.TrafficStats.Transmitted > 0)" class="mt-2">
<small class="text-muted">
Traffic: <i class="fa-solid fa-arrow-down me-1"></i>{{ humanFileSize(interfaces.TrafficStats.Received) }}/s
<i class="fa-solid fa-arrow-up ms-1 me-1"></i>{{ humanFileSize(interfaces.TrafficStats.Transmitted) }}/s
</small>
</div>
</div> </div>
<div class="col-12 col-lg-4 text-lg-end"> <div class="col-12 col-lg-4 text-lg-end">
<a class="btn-link" href="#" :title="$t('interfaces.interface.button-show-config')" @click.prevent="viewedInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-eye"></i></a> <a class="btn-link" href="#" :title="$t('interfaces.interface.button-show-config')" @click.prevent="viewedInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-eye"></i></a>
@@ -457,19 +451,14 @@ onMounted(async () => {
<td v-if="interfaces.GetSelected.Mode==='client'">{{peer.Endpoint.Value}}</td> <td v-if="interfaces.GetSelected.Mode==='client'">{{peer.Endpoint.Value}}</td>
<td v-if="peers.hasStatistics"> <td v-if="peers.hasStatistics">
<div v-if="peers.Statistics(peer.Identifier).IsConnected"> <div v-if="peers.Statistics(peer.Identifier).IsConnected">
<span class="badge rounded-pill bg-success" :title="$t('interfaces.peer-connected')"><i class="fa-solid fa-link"></i></span> <small class="text-muted" :title="$t('interfaces.peer-handshake') + ' ' + peers.Statistics(peer.Identifier).LastHandshake"><i class="fa-solid fa-circle-info"></i></small> <span class="badge rounded-pill bg-success" :title="$t('interfaces.peer-connected')"><i class="fa-solid fa-link"></i></span> <span :title="$t('interfaces.peer-handshake') + ' ' + peers.Statistics(peer.Identifier).LastHandshake">{{ $t('interfaces.peer-connected') }}</span>
</div> </div>
<div v-else> <div v-else>
<span class="badge rounded-pill bg-light" :title="$t('interfaces.peer-not-connected')"><i class="fa-solid fa-link-slash"></i></span> <span class="badge rounded-pill bg-light" :title="$t('interfaces.peer-not-connected')"><i class="fa-solid fa-link-slash"></i></span>
</div> </div>
</td> </td>
<td v-if="peers.hasStatistics" > <td v-if="peers.hasStatistics" >
<div class="d-flex flex-column"> <span class="text-center" >{{ humanFileSize(peers.Statistics(peer.Identifier).BytesReceived) }} / {{ humanFileSize(peers.Statistics(peer.Identifier).BytesTransmitted) }}</span>
<span :title="humanFileSize(peers.Statistics(peer.Identifier).BytesReceived) + ' / ' + humanFileSize(peers.Statistics(peer.Identifier).BytesTransmitted)">
<i class="fa-solid fa-arrow-down me-1"></i>{{ humanFileSize(peers.TrafficStats(peer.Identifier).Received) }}/s
<i class="fa-solid fa-arrow-up ms-1 me-1"></i>{{ humanFileSize(peers.TrafficStats(peer.Identifier).Transmitted) }}/s
</span>
</div>
</td> </td>
<td class="text-center"> <td class="text-center">
<a href="#" :title="$t('interfaces.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a> <a href="#" :title="$t('interfaces.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a>

7
go.mod
View File

@@ -12,7 +12,6 @@ require (
github.com/go-playground/validator/v10 v10.30.1 github.com/go-playground/validator/v10 v10.30.1
github.com/go-webauthn/webauthn v0.15.0 github.com/go-webauthn/webauthn v0.15.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/prometheus-community/pro-bing v0.7.0 github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
@@ -22,9 +21,9 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0 github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.5 github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1 github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.47.0 golang.org/x/crypto v0.46.0
golang.org/x/oauth2 v0.34.0 golang.org/x/oauth2 v0.34.0
golang.org/x/sys v0.40.0 golang.org/x/sys v0.39.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
@@ -97,7 +96,7 @@ require (
golang.org/x/mod v0.31.0 // indirect golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect golang.org/x/tools v0.40.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect

14
go.sum
View File

@@ -130,8 +130,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
@@ -272,8 +270,8 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -334,8 +332,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -364,8 +362,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View File

@@ -626,7 +626,7 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
if err != nil { if err != nil {
slog.Warn("failed to executed shell command", slog.Warn("failed to executed shell command",
"command", commandWithInterfaceName, "stdin", stdin, "output", string(out), "error", err) "command", commandWithInterfaceName, "stdin", stdin, "output", string(out), "error", err)
return fmt.Errorf("failed to execute shell command %s: %w", commandWithInterfaceName, err) return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err)
} }
slog.Debug("executed shell command", slog.Debug("executed shell command",
"command", commandWithInterfaceName, "command", commandWithInterfaceName,

View File

@@ -1,8 +1,6 @@
package logging package logging
import ( import (
"bufio"
"net"
"net/http" "net/http"
) )
@@ -40,12 +38,6 @@ func (w *writerWrapper) Write(data []byte) (int, error) {
return n, err return n, err
} }
// Hijack wraps the Hijack method of the ResponseWriter and returns the hijacked connection.
// This is required for websockets to work.
func (w *writerWrapper) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return http.NewResponseController(w.ResponseWriter).Hijack()
}
// newWriterWrapper returns a new writerWrapper that wraps the given http.ResponseWriter. // newWriterWrapper returns a new writerWrapper that wraps the given http.ResponseWriter.
// It initializes the StatusCode to http.StatusOK. // It initializes the StatusCode to http.StatusOK.
func newWriterWrapper(w http.ResponseWriter) *writerWrapper { func newWriterWrapper(w http.ResponseWriter) *writerWrapper {

View File

@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-pkgz/routegroup" "github.com/go-pkgz/routegroup"
@@ -448,17 +449,7 @@ func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc {
// isValidReturnUrl checks if the given return URL matches the configured external URL of the application. // isValidReturnUrl checks if the given return URL matches the configured external URL of the application.
func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool { func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
expectedUrl, err := url.Parse(e.cfg.Web.ExternalUrl) if !strings.HasPrefix(returnUrl, e.cfg.Web.ExternalUrl) {
if err != nil {
return false
}
returnUrlParsed, err := url.Parse(returnUrl)
if err != nil {
return false
}
if returnUrlParsed.Scheme != expectedUrl.Scheme || returnUrlParsed.Host != expectedUrl.Host {
return false return false
} }

View File

@@ -1,100 +0,0 @@
package handlers
import (
"context"
"net/http"
"strings"
"sync"
"github.com/go-pkgz/routegroup"
"github.com/gorilla/websocket"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
type WebsocketEventBus interface {
Subscribe(topic string, fn any) error
Unsubscribe(topic string, fn any) error
}
type WebsocketEndpoint struct {
authenticator Authenticator
bus WebsocketEventBus
upgrader websocket.Upgrader
}
func NewWebsocketEndpoint(cfg *config.Config, auth Authenticator, bus WebsocketEventBus) *WebsocketEndpoint {
return &WebsocketEndpoint{
authenticator: auth,
bus: bus,
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return strings.HasPrefix(origin, cfg.Web.ExternalUrl)
},
},
}
}
func (e WebsocketEndpoint) GetName() string {
return "WebsocketEndpoint"
}
func (e WebsocketEndpoint) RegisterRoutes(g *routegroup.Bundle) {
g.With(e.authenticator.LoggedIn()).HandleFunc("GET /ws", e.handleWebsocket())
}
// wsMessage represents a message sent over websocket to the frontend
type wsMessage struct {
Type string `json:"type"` // either "peer_stats" or "interface_stats"
Data any `json:"data"` // domain.TrafficDelta
}
func (e WebsocketEndpoint) handleWebsocket() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
conn, err := e.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
writeMutex := sync.Mutex{}
writeJSON := func(msg wsMessage) error {
writeMutex.Lock()
defer writeMutex.Unlock()
return conn.WriteJSON(msg)
}
peerStatsHandler := func(status domain.TrafficDelta) {
_ = writeJSON(wsMessage{Type: "peer_stats", Data: status})
}
interfaceStatsHandler := func(status domain.TrafficDelta) {
_ = writeJSON(wsMessage{Type: "interface_stats", Data: status})
}
_ = e.bus.Subscribe(app.TopicPeerStatsUpdated, peerStatsHandler)
defer e.bus.Unsubscribe(app.TopicPeerStatsUpdated, peerStatsHandler)
_ = e.bus.Subscribe(app.TopicInterfaceStatsUpdated, interfaceStatsHandler)
defer e.bus.Unsubscribe(app.TopicInterfaceStatsUpdated, interfaceStatsHandler)
// Keep connection open until client disconnects or context is cancelled
go func() {
for {
if _, _, err := conn.ReadMessage(); err != nil {
cancel()
return
}
}
}()
<-ctx.Done()
}
}

View File

@@ -26,7 +26,6 @@ const TopicUserEnabled = "user:enabled"
const TopicInterfaceCreated = "interface:created" const TopicInterfaceCreated = "interface:created"
const TopicInterfaceUpdated = "interface:updated" const TopicInterfaceUpdated = "interface:updated"
const TopicInterfaceDeleted = "interface:deleted" const TopicInterfaceDeleted = "interface:deleted"
const TopicInterfaceStatsUpdated = "interface:stats:updated"
// endregion interface-events // endregion interface-events
@@ -38,7 +37,6 @@ const TopicPeerUpdated = "peer:updated"
const TopicPeerInterfaceUpdated = "peer:interface:updated" const TopicPeerInterfaceUpdated = "peer:interface:updated"
const TopicPeerIdentifierUpdated = "peer:identifier:updated" const TopicPeerIdentifierUpdated = "peer:identifier:updated"
const TopicPeerStateChanged = "peer:state:changed" const TopicPeerStateChanged = "peer:state:changed"
const TopicPeerStatsUpdated = "peer:stats:updated"
// endregion peer-events // endregion peer-events

View File

@@ -121,25 +121,15 @@ func (c *StatisticsCollector) collectInterfaceData(ctx context.Context) {
"error", err) "error", err)
continue continue
} }
now := time.Now()
err = c.db.UpdateInterfaceStatus(ctx, in.Identifier, err = c.db.UpdateInterfaceStatus(ctx, in.Identifier,
func(i *domain.InterfaceStatus) (*domain.InterfaceStatus, error) { func(i *domain.InterfaceStatus) (*domain.InterfaceStatus, error) {
td := domain.CalculateTrafficDelta( i.UpdatedAt = time.Now()
string(in.Identifier),
i.UpdatedAt, now,
i.BytesTransmitted, physicalInterface.BytesUpload,
i.BytesReceived, physicalInterface.BytesDownload,
)
i.UpdatedAt = now
i.BytesReceived = physicalInterface.BytesDownload i.BytesReceived = physicalInterface.BytesDownload
i.BytesTransmitted = physicalInterface.BytesUpload i.BytesTransmitted = physicalInterface.BytesUpload
// Update prometheus metrics // Update prometheus metrics
go c.updateInterfaceMetrics(*i) go c.updateInterfaceMetrics(*i)
// Publish stats update event
c.bus.Publish(app.TopicInterfaceStatsUpdated, td)
return i, nil return i, nil
}) })
if err != nil { if err != nil {
@@ -182,7 +172,6 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
slog.Warn("failed to fetch peers for data collection", "interface", in.Identifier, "error", err) slog.Warn("failed to fetch peers for data collection", "interface", in.Identifier, "error", err)
continue continue
} }
now := time.Now()
for _, peer := range peers { for _, peer := range peers {
var connectionStateChanged bool var connectionStateChanged bool
var newPeerStatus domain.PeerStatus var newPeerStatus domain.PeerStatus
@@ -195,15 +184,8 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
lastHandshake = &peer.LastHandshake lastHandshake = &peer.LastHandshake
} }
td := domain.CalculateTrafficDelta(
string(peer.Identifier),
p.UpdatedAt, now,
p.BytesTransmitted, peer.BytesDownload,
p.BytesReceived, peer.BytesUpload,
)
// calculate if session was restarted // calculate if session was restarted
p.UpdatedAt = now p.UpdatedAt = time.Now()
p.LastSessionStart = getSessionStartTime(*p, peer.BytesUpload, peer.BytesDownload, p.LastSessionStart = getSessionStartTime(*p, peer.BytesUpload, peer.BytesDownload,
lastHandshake) lastHandshake)
p.BytesReceived = peer.BytesUpload // store bytes that where uploaded from the peer and received by the server p.BytesReceived = peer.BytesUpload // store bytes that where uploaded from the peer and received by the server
@@ -213,8 +195,7 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
p.CalcConnected() p.CalcConnected()
if wasConnected != p.IsConnected { if wasConnected != p.IsConnected {
slog.Debug("peer connection state changed", slog.Debug("peer connection state changed", "peer", peer.Identifier, "connected", p.IsConnected)
"peer", peer.Identifier, "connected", p.IsConnected)
connectionStateChanged = true connectionStateChanged = true
newPeerStatus = *p // store new status for event publishing newPeerStatus = *p // store new status for event publishing
} }
@@ -222,9 +203,6 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
// Update prometheus metrics // Update prometheus metrics
go c.updatePeerMetrics(ctx, *p) go c.updatePeerMetrics(ctx, *p)
// Publish stats update event
c.bus.Publish(app.TopicPeerStatsUpdated, td)
return p, nil return p, nil
}) })
if err != nil { if err != nil {

View File

@@ -985,26 +985,7 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain
peer.InterfaceIdentifier = in.Identifier peer.InterfaceIdentifier = in.Identifier
peer.EndpointPublicKey = domain.NewConfigOption(in.PublicKey, true) peer.EndpointPublicKey = domain.NewConfigOption(in.PublicKey, true)
peer.AllowedIPsStr = domain.NewConfigOption(in.PeerDefAllowedIPsStr, true) peer.AllowedIPsStr = domain.NewConfigOption(in.PeerDefAllowedIPsStr, true)
peer.Interface.Addresses = p.AllowedIPs // use allowed IP's as the peer IP's TODO: Should this also match server interface address' prefix length?
// split allowed IP's into interface addresses and extra allowed IP's
var interfaceAddresses []domain.Cidr
var extraAllowedIPs []domain.Cidr
for _, allowedIP := range p.AllowedIPs {
isHost := (allowedIP.IsV4() && allowedIP.NetLength == 32) || (!allowedIP.IsV4() && allowedIP.NetLength == 128)
isNetworkAddr := allowedIP.Addr == allowedIP.NetworkAddr().Addr
// Network addresses (e.g. 10.0.0.0/24) will always be extra allowed IP's.
// For IP addresses, such as 10.0.0.1/24, it is challenging to tell whether it is an interface address or
// an extra allowed IP, therefore we treat such addresses as interface addresses.
if !isHost && isNetworkAddr {
extraAllowedIPs = append(extraAllowedIPs, allowedIP)
} else {
interfaceAddresses = append(interfaceAddresses, allowedIP)
}
}
peer.Interface.Addresses = interfaceAddresses
peer.ExtraAllowedIPsStr = domain.CidrsToString(extraAllowedIPs)
peer.Interface.DnsStr = domain.NewConfigOption(in.PeerDefDnsStr, true) peer.Interface.DnsStr = domain.NewConfigOption(in.PeerDefDnsStr, true)
peer.Interface.DnsSearchStr = domain.NewConfigOption(in.PeerDefDnsSearchStr, true) peer.Interface.DnsSearchStr = domain.NewConfigOption(in.PeerDefDnsSearchStr, true)
peer.Interface.Mtu = domain.NewConfigOption(in.PeerDefMtu, true) peer.Interface.Mtu = domain.NewConfigOption(in.PeerDefMtu, true)

View File

@@ -1,94 +0,0 @@
package wireguard
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/h44z/wg-portal/internal/domain"
)
func TestImportPeer_AddressMapping(t *testing.T) {
tests := []struct {
name string
allowedIPs []string
expectedInterface []string
expectedExtraAllowed string
}{
{
name: "IPv4 host address",
allowedIPs: []string{"10.0.0.1/32"},
expectedInterface: []string{"10.0.0.1/32"},
expectedExtraAllowed: "",
},
{
name: "IPv6 host address",
allowedIPs: []string{"fd00::1/128"},
expectedInterface: []string{"fd00::1/128"},
expectedExtraAllowed: "",
},
{
name: "IPv4 network address",
allowedIPs: []string{"10.0.1.0/24"},
expectedInterface: []string{},
expectedExtraAllowed: "10.0.1.0/24",
},
{
name: "IPv4 normal address with mask",
allowedIPs: []string{"10.0.1.5/24"},
expectedInterface: []string{"10.0.1.5/24"},
expectedExtraAllowed: "",
},
{
name: "Mixed addresses",
allowedIPs: []string{
"10.0.0.1/32", "192.168.1.0/24", "172.16.0.5/24", "fd00::1/128", "fd00:1::/64",
},
expectedInterface: []string{"10.0.0.1/32", "172.16.0.5/24", "fd00::1/128"},
expectedExtraAllowed: "192.168.1.0/24,fd00:1::/64",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := &mockDB{}
m := Manager{
db: db,
}
iface := &domain.Interface{
Identifier: "wg0",
Type: domain.InterfaceTypeServer,
}
allowedIPs := make([]domain.Cidr, len(tt.allowedIPs))
for i, s := range tt.allowedIPs {
cidr, _ := domain.CidrFromString(s)
allowedIPs[i] = cidr
}
p := &domain.PhysicalPeer{
Identifier: "peer1",
KeyPair: domain.KeyPair{PublicKey: "peer1-public-key-is-long-enough"},
AllowedIPs: allowedIPs,
}
err := m.importPeer(context.Background(), iface, p)
assert.NoError(t, err)
savedPeer := db.savedPeers["peer1"]
assert.NotNil(t, savedPeer)
// Check interface addresses
actualInterface := make([]string, len(savedPeer.Interface.Addresses))
for i, addr := range savedPeer.Interface.Addresses {
actualInterface[i] = addr.String()
}
assert.ElementsMatch(t, tt.expectedInterface, actualInterface)
// Check extra allowed IPs
assert.Equal(t, tt.expectedExtraAllowed, savedPeer.ExtraAllowedIPsStr)
})
}
}

View File

@@ -61,25 +61,3 @@ func (r PingerResult) AverageRtt() time.Duration {
} }
return total / time.Duration(len(r.Rtts)) return total / time.Duration(len(r.Rtts))
} }
type TrafficDelta struct {
EntityId string `json:"EntityId"` // Either peerId or interfaceId
BytesReceivedPerSecond uint64 `json:"BytesReceived"`
BytesTransmittedPerSecond uint64 `json:"BytesTransmitted"`
}
func CalculateTrafficDelta(id string, oldTime, newTime time.Time, oldTx, newTx, oldRx, newRx uint64) TrafficDelta {
timeDiff := uint64(newTime.Sub(oldTime).Seconds())
if timeDiff == 0 {
return TrafficDelta{
EntityId: id,
BytesReceivedPerSecond: 0,
BytesTransmittedPerSecond: 0,
}
}
return TrafficDelta{
EntityId: id,
BytesReceivedPerSecond: (newRx - oldRx) / timeDiff,
BytesTransmittedPerSecond: (newTx - oldTx) / timeDiff,
}
}