feat: add simple audit ui

This commit is contained in:
Christoph Haas 2025-03-29 16:42:31 +01:00
parent a49cfa6343
commit 6cbccf6d43
23 changed files with 2098 additions and 1557 deletions

View File

@ -52,10 +52,6 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL or Post
For the complete documentation visit [wgportal.org](https://wgportal.org).
## V2 TODOs
* Audit UI
## What is out of scope
* Automatic generation or application of any `iptables` or `nftables` rules.

View File

@ -71,6 +71,8 @@ func main() {
queueSize := 100
eventBus := evbus.New(queueSize)
auditManager := audit.NewManager(database)
auditRecorder, err := audit.NewAuditRecorder(cfg, eventBus, database)
internal.AssertNoError(err)
auditRecorder.StartBackgroundJobs(ctx)
@ -115,6 +117,7 @@ func main() {
apiV0BackendPeers := backendV0.NewPeerService(cfg, wireGuardManager, cfgFileManager, mailManager)
apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator)
apiV0EndpointAudit := handlersV0.NewAuditEndpoint(cfg, apiV0Auth, auditManager)
apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers)
apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces)
apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers)
@ -123,6 +126,7 @@ func main() {
apiFrontend := handlersV0.NewRestApi(apiV0Session,
apiV0EndpointAuth,
apiV0EndpointAudit,
apiV0EndpointUsers,
apiV0EndpointInterfaces,
apiV0EndpointPeers,

View File

@ -45,16 +45,14 @@ definitions:
models.Interface:
properties:
Addresses:
description: Addresses is a list of IP addresses (in CIDR format) that are
assigned to the interface.
description: Addresses is a list of IP addresses (in CIDR format) that are assigned to the interface.
example:
- 10.11.12.1/24
items:
type: string
type: array
Disabled:
description: Disabled is a flag that specifies if the interface is enabled
(up) or not (down). Disabled interfaces are not able to accept connections.
description: Disabled is a flag that specifies if the interface is enabled (up) or not (down). Disabled interfaces are not able to accept connections.
example: false
type: boolean
DisabledReason:
@ -67,45 +65,38 @@ definitions:
maxLength: 64
type: string
Dns:
description: Dns is a list of DNS servers that should be set if the interface
is up.
description: Dns is a list of DNS servers that should be set if the interface is up.
example:
- 1.1.1.1
items:
type: string
type: array
DnsSearch:
description: DnsSearch is the dns search option string that should be set
if the interface is up, will be appended to Dns servers.
description: DnsSearch is the dns search option string that should be set if the interface is up, will be appended to Dns servers.
example:
- wg.local
items:
type: string
type: array
EnabledPeers:
description: EnabledPeers is the number of enabled peers for this interface.
Only enabled peers are able to connect.
description: EnabledPeers is the number of enabled peers for this interface. Only enabled peers are able to connect.
readOnly: true
type: integer
FirewallMark:
description: FirewallMark is an optional firewall mark which is used to handle
interface traffic.
description: FirewallMark is an optional firewall mark which is used to handle interface traffic.
type: integer
Identifier:
description: Identifier is the unique identifier of the interface. It is always
equal to the device name of the interface.
description: Identifier is the unique identifier of the interface. It is always equal to the device name of the interface.
example: wg0
type: string
ListenPort:
description: 'ListenPort is the listening port, for example: 51820. The listening
port is only required for server interfaces.'
description: 'ListenPort is the listening port, for example: 51820. The listening port is only required for server interfaces.'
example: 51820
maximum: 65535
minimum: 1
type: integer
Mode:
description: Mode is the interface type, either 'server', 'client' or 'any'.
The mode specifies how WireGuard Portal handles peers for this interface.
description: Mode is the interface type, either 'server', 'client' or 'any'. The mode specifies how WireGuard Portal handles peers for this interface.
enum:
- server
- client
@ -119,8 +110,7 @@ definitions:
minimum: 1
type: integer
PeerDefAllowedIPs:
description: PeerDefAllowedIPs specifies the default allowed IP addresses
for a new peer.
description: PeerDefAllowedIPs specifies the default allowed IP addresses for a new peer.
example:
- 10.11.12.0/24
items:
@ -134,8 +124,7 @@ definitions:
type: string
type: array
PeerDefDnsSearch:
description: PeerDefDnsSearch specifies the default dns search options for
a new peer.
description: PeerDefDnsSearch specifies the default dns search options for a new peer.
example:
- wg.local
items:
@ -146,64 +135,52 @@ definitions:
example: wg.example.com:51820
type: string
PeerDefFirewallMark:
description: PeerDefFirewallMark specifies the default firewall mark for a
new peer.
description: PeerDefFirewallMark specifies the default firewall mark for a new peer.
type: integer
PeerDefMtu:
description: PeerDefMtu specifies the default device MTU for a new peer.
example: 1420
type: integer
PeerDefNetwork:
description: PeerDefNetwork specifies the default subnets from which new peers
will get their IP addresses. The subnet is specified in CIDR format.
description: PeerDefNetwork specifies the default subnets from which new peers will get their IP addresses. The subnet is specified in CIDR format.
example:
- 10.11.12.0/24
items:
type: string
type: array
PeerDefPersistentKeepalive:
description: PeerDefPersistentKeepalive specifies the default persistent keep-alive
value in seconds for a new peer.
description: PeerDefPersistentKeepalive specifies the default persistent keep-alive value in seconds for a new peer.
example: 25
type: integer
PeerDefPostDown:
description: PeerDefPostDown specifies the default action that is executed
after the device is down for a new peer.
description: PeerDefPostDown specifies the default action that is executed after the device is down for a new peer.
type: string
PeerDefPostUp:
description: PeerDefPostUp specifies the default action that is executed after
the device is up for a new peer.
description: PeerDefPostUp specifies the default action that is executed after the device is up for a new peer.
type: string
PeerDefPreDown:
description: PeerDefPreDown specifies the default action that is executed
before the device is down for a new peer.
description: PeerDefPreDown specifies the default action that is executed before the device is down for a new peer.
type: string
PeerDefPreUp:
description: PeerDefPreUp specifies the default action that is executed before
the device is up for a new peer.
description: PeerDefPreUp specifies the default action that is executed before the device is up for a new peer.
type: string
PeerDefRoutingTable:
description: PeerDefRoutingTable specifies the default routing table for a
new peer.
description: PeerDefRoutingTable specifies the default routing table for a new peer.
type: string
PostDown:
description: PostDown is an optional action that is executed after the device
is down.
description: PostDown is an optional action that is executed after the device is down.
example: echo 'Interface is down'
type: string
PostUp:
description: PostUp is an optional action that is executed after the device
is up.
description: PostUp is an optional action that is executed after the device is up.
example: iptables -A FORWARD -i %i -j ACCEPT
type: string
PreDown:
description: PreDown is an optional action that is executed before the device
is down.
description: PreDown is an optional action that is executed before the device is down.
example: iptables -D FORWARD -i %i -j ACCEPT
type: string
PreUp:
description: PreUp is an optional action that is executed before the device
is up.
description: PreUp is an optional action that is executed before the device is up.
example: echo 'Interface is up'
type: string
PrivateKey:
@ -211,17 +188,14 @@ definitions:
example: gI6EdUSYvn8ugXOt8QQD6Yc+JyiZxIhp3GInSWRfWGE=
type: string
PublicKey:
description: PublicKey is the public key of the server interface. The public
key is used by peers to connect to the server.
description: PublicKey is the public key of the server interface. The public key is used by peers to connect to the server.
example: HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw=
type: string
RoutingTable:
description: RoutingTable is an optional routing table which is used to route
interface traffic.
description: RoutingTable is an optional routing table which is used to route interface traffic.
type: string
SaveConfig:
description: SaveConfig is a flag that specifies if the configuration should
be saved to the configuration file (wgX.conf in wg-quick format).
description: SaveConfig is a flag that specifies if the configuration should be saved to the configuration file (wgX.conf in wg-quick format).
example: false
type: boolean
TotalPeers:
@ -252,8 +226,7 @@ definitions:
models.Peer:
properties:
Addresses:
description: Addresses is a list of IP addresses in CIDR format (both IPv4
and IPv6) for the peer.
description: Addresses is a list of IP addresses in CIDR format (both IPv4 and IPv6) for the peer.
example:
- 10.11.12.2/24
items:
@ -264,13 +237,11 @@ definitions:
- $ref: '#/definitions/models.ConfigOption-array_string'
description: AllowedIPs is a list of allowed IP subnets for the peer.
CheckAliveAddress:
description: CheckAliveAddress is an optional ip address or DNS name that
is used for ping checks.
description: CheckAliveAddress is an optional ip address or DNS name that is used for ping checks.
example: 1.1.1.1
type: string
Disabled:
description: Disabled is a flag that specifies if the peer is enabled or not.
Disabled peers are not able to connect.
description: Disabled is a flag that specifies if the peer is enabled or not. Disabled peers are not able to connect.
example: false
type: boolean
DisabledReason:
@ -285,13 +256,11 @@ definitions:
Dns:
allOf:
- $ref: '#/definitions/models.ConfigOption-array_string'
description: Dns is a list of DNS servers that should be set if the peer interface
is up.
description: Dns is a list of DNS servers that should be set if the peer interface is up.
DnsSearch:
allOf:
- $ref: '#/definitions/models.ConfigOption-array_string'
description: DnsSearch is the dns search option string that should be set
if the peer interface is up, will be appended to Dns servers.
description: DnsSearch is the dns search option string that should be set if the peer interface is up, will be appended to Dns servers.
Endpoint:
allOf:
- $ref: '#/definitions/models.ConfigOption-string'
@ -301,28 +270,23 @@ definitions:
- $ref: '#/definitions/models.ConfigOption-string'
description: EndpointPublicKey is the endpoint public key.
ExpiresAt:
description: ExpiresAt is the expiry date of the peer in YYYY-MM-DD format.
An expired peer is not able to connect.
description: ExpiresAt is the expiry date of the peer in YYYY-MM-DD format. An expired peer is not able to connect.
type: string
ExtraAllowedIPs:
description: ExtraAllowedIPs is a list of additional allowed IP subnets for
the peer. These allowed IP subnets are added on the server side.
description: ExtraAllowedIPs is a list of additional allowed IP subnets for the peer. These allowed IP subnets are added on the server side.
items:
type: string
type: array
FirewallMark:
allOf:
- $ref: '#/definitions/models.ConfigOption-uint32'
description: FirewallMark is an optional firewall mark which is used to handle
peer traffic.
description: FirewallMark is an optional firewall mark which is used to handle peer traffic.
Identifier:
description: Identifier is the unique identifier of the peer. It is always
equal to the public key of the peer.
description: Identifier is the unique identifier of the peer. It is always equal to the public key of the peer.
example: xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=
type: string
InterfaceIdentifier:
description: InterfaceIdentifier is the identifier of the interface the peer
is linked to.
description: InterfaceIdentifier is the identifier of the interface the peer is linked to.
example: wg0
type: string
Mode:
@ -344,28 +308,23 @@ definitions:
PersistentKeepalive:
allOf:
- $ref: '#/definitions/models.ConfigOption-int'
description: PersistentKeepalive is the optional persistent keep-alive interval
in seconds.
description: PersistentKeepalive is the optional persistent keep-alive interval in seconds.
PostDown:
allOf:
- $ref: '#/definitions/models.ConfigOption-string'
description: PostDown is an optional action that is executed after the device
is down.
description: PostDown is an optional action that is executed after the device is down.
PostUp:
allOf:
- $ref: '#/definitions/models.ConfigOption-string'
description: PostUp is an optional action that is executed after the device
is up.
description: PostUp is an optional action that is executed after the device is up.
PreDown:
allOf:
- $ref: '#/definitions/models.ConfigOption-string'
description: PreDown is an optional action that is executed before the device
is down.
description: PreDown is an optional action that is executed before the device is down.
PreUp:
allOf:
- $ref: '#/definitions/models.ConfigOption-string'
description: PreUp is an optional action that is executed before the device
is up.
description: PreUp is an optional action that is executed before the device is up.
PresharedKey:
description: PresharedKey is the optional pre-shared Key of the peer.
example: yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=
@ -381,8 +340,7 @@ definitions:
RoutingTable:
allOf:
- $ref: '#/definitions/models.ConfigOption-string'
description: RoutingTable is an optional routing table which is used to route
peer traffic.
description: RoutingTable is an optional routing table which is used to route peer traffic.
UserIdentifier:
description: UserIdentifier is the identifier of the user that owns the peer.
example: uid-1234567
@ -430,18 +388,15 @@ definitions:
models.ProvisioningRequest:
properties:
InterfaceIdentifier:
description: InterfaceIdentifier is the identifier of the WireGuard interface
the peer should be linked to.
description: InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to.
example: wg0
type: string
PresharedKey:
description: PresharedKey is the optional pre-shared key of the peer. If no
pre-shared key is set, a new key is generated.
description: PresharedKey is the optional pre-shared key of the peer. If no pre-shared key is set, a new key is generated.
example: yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=
type: string
PublicKey:
description: PublicKey is the optional public key of the peer. If no public
key is set, a new key pair is generated.
description: PublicKey is the optional public key of the peer. If no public key is set, a new key pair is generated.
example: xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=
type: string
UserIdentifier:
@ -456,14 +411,12 @@ definitions:
models.User:
properties:
ApiEnabled:
description: If this field is set, the user is allowed to use the RESTful
API. This field is read-only.
description: If this field is set, the user is allowed to use the RESTful API. This field is read-only.
example: false
readOnly: true
type: boolean
ApiToken:
description: The API token of the user. This field is never populated on bulk
read operations.
description: The API token of the user. This field is never populated on bulk read operations.
example: ""
maxLength: 64
minLength: 32
@ -502,8 +455,7 @@ definitions:
example: Muster
type: string
Locked:
description: If this field is set, the user is locked and thus unable to log
in to WireGuard Portal.
description: If this field is set, the user is locked and thus unable to log in to WireGuard Portal.
example: false
type: boolean
LockedReason:
@ -515,8 +467,7 @@ definitions:
example: some sample notes
type: string
Password:
description: The password of the user. This field is never populated on read
operations.
description: The password of the user. This field is never populated on read operations.
example: ""
maxLength: 64
minLength: 16
@ -567,39 +518,33 @@ definitions:
example: My iPhone
type: string
Identifier:
description: Identifier is the unique identifier of the peer. It equals the
public key of the peer.
description: Identifier is the unique identifier of the peer. It equals the public key of the peer.
example: peer-1234567
type: string
InterfaceIdentifier:
description: InterfaceIdentifier is the unique identifier of the WireGuard
Portal device the peer is connected to.
description: InterfaceIdentifier is the unique identifier of the WireGuard Portal device the peer is connected to.
example: wg0
type: string
IpAddresses:
description: IPAddresses is a list of IP addresses in CIDR format assigned
to the peer.
description: IPAddresses is a list of IP addresses in CIDR format assigned to the peer.
example:
- 10.11.12.2/24
items:
type: string
type: array
IsDisabled:
description: IsDisabled is a flag that specifies if the peer is enabled or
not. Disabled peers are not able to connect.
description: IsDisabled is a flag that specifies if the peer is enabled or not. Disabled peers are not able to connect.
example: true
type: boolean
type: object
models.UserMetrics:
properties:
BytesReceived:
description: The total number of bytes received by the user. This is the sum
of all bytes received by the peers linked to the user.
description: The total number of bytes received by the user. This is the sum of all bytes received by the peers linked to the user.
example: 123456789
type: integer
BytesTransmitted:
description: The total number of bytes transmitted by the user. This is the
sum of all bytes transmitted by the peers linked to the user.
description: The total number of bytes transmitted by the user. This is the sum of all bytes transmitted by the peers linked to the user.
example: 123456789
type: integer
PeerCount:
@ -607,8 +552,7 @@ definitions:
example: 2
type: integer
PeerMetrics:
description: PeerMetrics represents the metrics of the peers linked to the
user.
description: PeerMetrics represents the metrics of the peers linked to the user.
items:
$ref: '#/definitions/models.PeerMetrics'
type: array
@ -970,8 +914,7 @@ paths:
tags:
- Peers
get:
description: Normal users can only access their own records. Admins can access
all records.
description: Normal users can only access their own records. Admins can access all records.
operationId: peers_handleByIdGet
parameters:
- description: The peer identifier (public key).
@ -1087,8 +1030,7 @@ paths:
- Peers
/peer/by-user/{id}:
get:
description: Normal users can only access their own records. Admins can access
all records.
description: Normal users can only access their own records. Admins can access all records.
operationId: peers_handleAllForUserGet
parameters:
- description: The user identifier.
@ -1163,8 +1105,7 @@ paths:
- Peers
/provisioning/data/peer-config:
get:
description: Normal users can only access their own record. Admins can access
all records.
description: Normal users can only access their own record. Admins can access all records.
operationId: provisioning_handlePeerConfigGet
parameters:
- description: The peer identifier (public key) that should be queried.
@ -1207,8 +1148,7 @@ paths:
- Provisioning
/provisioning/data/peer-qr:
get:
description: Normal users can only access their own record. Admins can access
all records.
description: Normal users can only access their own record. Admins can access all records.
operationId: provisioning_handlePeerQrGet
parameters:
- description: The peer identifier (public key) that should be queried.
@ -1251,17 +1191,14 @@ paths:
- Provisioning
/provisioning/data/user-info:
get:
description: Normal users can only access their own record. Admins can access
all records.
description: Normal users can only access their own record. Admins can access all records.
operationId: provisioning_handleUserInfoGet
parameters:
- description: The user identifier that should be queried. If not set, the authenticated
user is used.
- description: The user identifier that should be queried. If not set, the authenticated user is used.
in: query
name: UserId
type: string
- description: The email address that should be queried. If UserId is set, this
is ignored.
- description: The email address that should be queried. If UserId is set, this is ignored.
in: query
name: Email
type: string
@ -1299,8 +1236,7 @@ paths:
- Provisioning
/provisioning/new-peer:
post:
description: Normal users can only create new peers if self provisioning is
allowed. Admins can always add new peers.
description: Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.
operationId: provisioning_handleNewPeerPost
parameters:
- description: Provisioning request model.
@ -1406,8 +1342,7 @@ paths:
tags:
- Users
get:
description: Normal users can only access their own record. Admins can access
all records.
description: Normal users can only access their own record. Admins can access all records.
operationId: users_handleByIdGet
parameters:
- description: The user identifier.

View File

@ -91,6 +91,7 @@ const currentYear = ref(new Date().getFullYear())
<div class="dropdown-menu">
<RouterLink :to="{ name: 'profile' }" class="dropdown-item"><i class="fas fa-user"></i> {{ $t('menu.profile') }}</RouterLink>
<RouterLink :to="{ name: 'settings' }" class="dropdown-item" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')"><i class="fas fa-gears"></i> {{ $t('menu.settings') }}</RouterLink>
<RouterLink :to="{ name: 'audit' }" class="dropdown-item" v-if="auth.IsAdmin"><i class="fas fa-file-shield"></i> {{ $t('menu.audit') }}</RouterLink>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" @click.prevent="auth.Logout"><i class="fas fa-sign-out-alt"></i> {{ $t('menu.logout') }}</a>
</div>

View File

@ -38,6 +38,7 @@
"lang": "Toggle Language",
"profile": "My Profile",
"settings": "Settings",
"audit": "Audit Log",
"login": "Login",
"logout": "Logout"
},
@ -188,6 +189,23 @@
"api-link": "API Documentation"
}
},
"audit": {
"headline": "Audit Log",
"abstract": "Here you can find the audit log of all actions performed in the WireGuard Portal.",
"no-entries": {
"headline": "No log entries available",
"abstract": "Currently, there are no audit logs recorded."
},
"entries-headline": "Log Entries",
"table-heading": {
"id": "#",
"time": "Time",
"user": "User",
"severity": "Severity",
"origin": "Origin",
"message": "Message"
}
},
"modals": {
"user-view": {
"headline": "User Account:",

View File

@ -56,6 +56,14 @@ const router = createRouter({
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/SettingsView.vue')
},
{
path: '/audit',
name: 'audit',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AuditView.vue')
}
],
linkActiveClass: "active",

View File

@ -0,0 +1,87 @@
import { defineStore } from 'pinia'
import {apiWrapper} from "@/helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification";
import { base64_url_encode } from '@/helpers/encoding';
const baseUrl = `/audit`
export const auditStore = defineStore('audit', {
state: () => ({
entries: [],
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
}),
getters: {
Count: (state) => state.entries.length,
FilteredCount: (state) => state.Filtered.length,
All: (state) => state.entries,
Filtered: (state) => {
if (!state.filter) {
return state.entries
}
return state.entries.filter((e) => {
return e.Timestamp.includes(state.filter) ||
e.Message.includes(state.filter) ||
e.Severity.includes(state.filter) ||
e.Origin.includes(state.filter)
})
},
FilteredAndPaged: (state) => {
return state.Filtered.slice(state.pageOffset, state.pageOffset + state.pageSize)
},
isFetching: (state) => state.fetching,
hasNextPage: (state) => state.pageOffset < (state.FilteredCount - state.pageSize),
hasPrevPage: (state) => state.pageOffset > 0,
currentPage: (state) => (state.pageOffset / state.pageSize)+1,
},
actions: {
afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
},
gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
},
nextPage() {
this.pageOffset += this.pageSize
this.calculatePages()
},
previousPage() {
this.pageOffset -= this.pageSize
this.calculatePages()
},
setEntries(entries) {
this.entries = entries
this.calculatePages()
this.fetching = false
},
async LoadEntries() {
this.fetching = true
return apiWrapper.get(`${baseUrl}/entries`)
.then(this.setEntries)
.catch(error => {
this.setEntries([])
console.log("Failed to load audit entries: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load audit entries!",
})
})
},
}
})

View File

@ -0,0 +1,96 @@
<script setup>
import { onMounted } from "vue";
import {auditStore} from "@/stores/audit";
const audit = auditStore()
onMounted(async () => {
await audit.LoadEntries()
})
</script>
<template>
<div class="page-header">
<h1>{{ $t('audit.headline') }}</h1>
</div>
<p class="lead">{{ $t('audit.abstract') }}</p>
<!-- Entry list -->
<div class="mt-4 row">
<div class="col-12 col-lg-6">
<h3>{{ $t('audit.entries-headline') }}</h3>
</div>
<div class="col-12 col-lg-6 text-lg-end">
<div class="form-group d-inline">
<div class="input-group mb-3">
<input v-model="audit.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="audit.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
</div>
</div>
</div>
</div>
<div class="mt-2 table-responsive">
<div v-if="audit.Count===0">
<h4>{{ $t('audit.no-entries.headline') }}</h4>
<p>{{ $t('audit.no-entries.abstract') }}</p>
</div>
<table v-if="audit.Count!==0" id="auditTable" class="table table-sm">
<thead>
<tr>
<th scope="col">{{ $t('audit.table-heading.id') }}</th>
<th class="text-center" scope="col">{{ $t('audit.table-heading.time') }}</th>
<th class="text-center" scope="col">{{ $t('audit.table-heading.severity') }}</th>
<th scope="col">{{ $t('audit.table-heading.user') }}</th>
<th scope="col">{{ $t('audit.table-heading.origin') }}</th>
<th scope="col">{{ $t('audit.table-heading.message') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in audit.FilteredAndPaged" :key="entry.Id">
<td>{{entry.Id}}</td>
<td>{{entry.Timestamp}}</td>
<td class="text-center"><span class="badge rounded-pill" :class="[ entry.Severity === 'low' ? 'bg-light' : entry.Severity === 'medium' ? 'bg-warning' : 'bg-danger']">{{entry.Severity}}</span></td>
<td>{{entry.ContextUser}}</td>
<td>{{entry.Origin}}</td>
<td>{{entry.Message}}</td>
</tr>
</tbody>
</table>
</div>
<hr>
<div class="mt-3">
<div class="row">
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{disabled:audit.pageOffset===0}" class="page-item">
<a class="page-link" @click="audit.previousPage">&laquo;</a>
</li>
<li v-for="page in audit.pages" :key="page" :class="{active:audit.currentPage===page}" class="page-item">
<a class="page-link" @click="audit.gotoPage(page)">{{page}}</a>
</li>
<li :class="{disabled:!audit.hasNextPage}" class="page-item">
<a class="page-link" @click="audit.nextPage">&raquo;</a>
</li>
</ul>
</div>
<div class="col-6">
<div class="form-group row">
<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="audit.pageSize" class="form-select" @click="audit.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('general.pagination.all') }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,12 +1,7 @@
<script setup>
import PeerViewModal from "../components/PeerViewModal.vue";
import { onMounted, ref } from "vue";
import { onMounted } from "vue";
import { profileStore } from "@/stores/profile";
import PeerEditModal from "@/components/PeerEditModal.vue";
import { settingsStore } from "@/stores/settings";
import { humanFileSize } from "@/helpers/utils";
import {RouterLink} from "vue-router";
import { authStore } from "../stores/auth";
const profile = profileStore()

View File

@ -3,16 +3,12 @@ import {userStore} from "@/stores/users";
import {ref,onMounted} from "vue";
import UserEditModal from "../components/UserEditModal.vue";
import UserViewModal from "../components/UserViewModal.vue";
import {notify} from "@kyvg/vue3-notification";
import {settingsStore} from "@/stores/settings";
const settings = settingsStore()
const users = userStore()
const editUserId = ref("")
const viewedUserId = ref("")
const selectAll = ref(false)
function toggleSelectAll() {

View File

@ -1049,4 +1049,16 @@ func (r *SqlRepo) SaveAuditEntry(ctx context.Context, entry *domain.AuditEntry)
return nil
}
// GetAllAuditEntries retrieves all audit entries from the database.
// The entries are ordered by timestamp, with the newest entries first.
func (r *SqlRepo) GetAllAuditEntries(ctx context.Context) ([]domain.AuditEntry, error) {
var entries []domain.AuditEntry
err := r.db.WithContext(ctx).Order("created_at desc").Find(&entries).Error
if err != nil {
return nil, err
}
return entries, nil
}
// endregion audit

View File

@ -11,6 +11,29 @@
},
"basePath": "/api/v0",
"paths": {
"/audit/entries": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Audit"
],
"summary": "Get all available audit entries. Ordered by timestamp.",
"operationId": "audit_handleEntriesGet",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.AuditEntry"
}
}
}
}
}
},
"/auth/login": {
"post": {
"produces": [
@ -171,6 +194,9 @@
"schema": {
"type": "string"
}
},
"500": {
"description": "Internal Server Error"
}
}
}
@ -1494,6 +1520,30 @@
}
},
"definitions": {
"model.AuditEntry": {
"type": "object",
"properties": {
"Message": {
"type": "string"
},
"ctx_user": {
"type": "string"
},
"id": {
"type": "integer"
},
"origin": {
"description": "origin: for example user auth, stats, ...",
"type": "string"
},
"severity": {
"type": "string"
},
"timestamp": {
"type": "string"
}
}
},
"model.ConfigOption-array_string": {
"type": "object",
"properties": {

View File

@ -1,5 +1,21 @@
basePath: /api/v0
definitions:
model.AuditEntry:
properties:
Message:
type: string
ctx_user:
type: string
id:
type: integer
origin:
description: 'origin: for example user auth, stats, ...'
type: string
severity:
type: string
timestamp:
type: string
type: object
model.ConfigOption-array_string:
properties:
Overridable:
@ -419,6 +435,21 @@ info:
title: WireGuard Portal SPA-UI API
version: "0.0"
paths:
/audit/entries:
get:
operationId: audit_handleEntriesGet
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.AuditEntry'
type: array
summary: Get all available audit entries. Ordered by timestamp.
tags:
- Audit
/auth/{provider}/callback:
get:
operationId: auth_handleOauthCallbackGet
@ -523,6 +554,8 @@ paths:
description: The JavaScript contents
schema:
type: string
"500":
description: Internal Server Error
summary: Get the dynamic frontend configuration javascript.
tags:
- Configuration

View File

@ -0,0 +1,69 @@
package handlers
import (
"context"
"net/http"
"github.com/go-pkgz/routegroup"
"github.com/h44z/wg-portal/internal/app/api/core/respond"
"github.com/h44z/wg-portal/internal/app/api/v0/model"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
type AuditService interface {
// GetAll returns all audit entries ordered by timestamp. Newest first.
GetAll(ctx context.Context) ([]domain.AuditEntry, error)
}
type AuditEndpoint struct {
cfg *config.Config
authenticator Authenticator
auditService AuditService
}
func NewAuditEndpoint(
cfg *config.Config,
authenticator Authenticator,
auditService AuditService,
) AuditEndpoint {
return AuditEndpoint{
cfg: cfg,
authenticator: authenticator,
auditService: auditService,
}
}
func (e AuditEndpoint) GetName() string {
return "AuditEndpoint"
}
func (e AuditEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup := g.Mount("/audit")
apiGroup.Use(e.authenticator.LoggedIn(ScopeAdmin))
apiGroup.HandleFunc("GET /entries", e.handleEntriesGet())
}
// handleExternalLoginProvidersGet returns a gorm Handler function.
//
// @ID audit_handleEntriesGet
// @Tags Audit
// @Summary Get all available audit entries. Ordered by timestamp.
// @Produce json
// @Success 200 {object} []model.AuditEntry
// @Router /audit/entries [get]
func (e AuditEndpoint) handleEntriesGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
providers, err := e.auditService.GetAll(r.Context())
if err != nil {
respond.JSON(w, http.StatusInternalServerError, model.Error{
Code: http.StatusInternalServerError, Message: err.Error(),
})
return
}
respond.JSON(w, http.StatusOK, model.NewAuditEntries(providers))
}
}

View File

@ -0,0 +1,36 @@
package model
import (
"github.com/h44z/wg-portal/internal/domain"
)
type AuditEntry struct {
Id uint64 `json:"Id"`
Timestamp string `json:"Timestamp"`
ContextUser string `json:"ContextUser"`
Severity string `json:"Severity"`
Origin string `json:"Origin"` // origin: for example user auth, stats, ...
Message string `message:"Message"`
}
// NewAuditEntry creates a REST API AuditEntry from a domain AuditEntry.
func NewAuditEntry(src domain.AuditEntry) AuditEntry {
return AuditEntry{
Id: src.UniqueId,
Timestamp: src.CreatedAt.Format("2006-01-02 15:04:05"),
ContextUser: src.ContextUser,
Severity: string(src.Severity),
Origin: src.Origin,
Message: src.Message,
}
}
// NewAuditEntries creates a slice of REST API AuditEntry from a slice of domain AuditEntry.
func NewAuditEntries(src []domain.AuditEntry) []AuditEntry {
dst := make([]AuditEntry, 0, len(src))
for _, entry := range src {
dst = append(dst, NewAuditEntry(entry))
}
return dst
}

View File

@ -0,0 +1,37 @@
package audit
import (
"context"
"fmt"
"github.com/h44z/wg-portal/internal/domain"
)
type ManagerDatabaseRepo interface {
// GetAllAuditEntries retrieves all audit entries from the database.
// The entries are ordered by timestamp, with the newest entries first.
GetAllAuditEntries(ctx context.Context) ([]domain.AuditEntry, error)
}
type Manager struct {
db ManagerDatabaseRepo
}
func NewManager(db ManagerDatabaseRepo) *Manager {
return &Manager{db: db}
}
func (m *Manager) GetAll(ctx context.Context) ([]domain.AuditEntry, error) {
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return nil, domain.ErrNoPermission
}
entries, err := m.db.GetAllAuditEntries(ctx)
if err != nil {
return nil, fmt.Errorf("failed to query audit entries: %w", err)
}
return entries, nil
}

View File

@ -0,0 +1,18 @@
package audit
import "github.com/h44z/wg-portal/internal/domain"
type AuthEvent struct {
Username string
Error string
}
type InterfaceEvent struct {
Interface domain.Interface
Action string
}
type PeerEvent struct {
Peer domain.Peer
Action string
}

View File

@ -78,22 +78,98 @@ func (r *Recorder) connectToMessageBus() error {
return nil // noting to do
}
if err := r.bus.Subscribe(app.TopicAuthLogin, r.authLoginEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuthLogin, err)
if err := r.bus.Subscribe(app.TopicAuditLoginSuccess, r.handleAuthEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuditLoginSuccess, err)
}
if err := r.bus.Subscribe(app.TopicAuditLoginFailed, r.handleAuthEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuditLoginFailed, err)
}
if err := r.bus.Subscribe(app.TopicAuditInterfaceChanged, r.handleInterfaceEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuditInterfaceChanged, err)
}
if err := r.bus.Subscribe(app.TopicAuditPeerChanged, r.handleInterfaceEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuditPeerChanged, err)
}
return nil
}
func (r *Recorder) authLoginEvent(userIdentifier domain.UserIdentifier) {
err := r.db.SaveAuditEntry(context.Background(), &domain.AuditEntry{
CreatedAt: time.Time{},
Severity: domain.AuditSeverityLevelLow,
Origin: "authLoginEvent",
Message: fmt.Sprintf("user %s logged in", userIdentifier),
})
func (r *Recorder) handleAuthEvent(event domain.AuditEventWrapper[AuthEvent]) {
err := r.db.SaveAuditEntry(context.Background(), r.authEventToAuditEntry(event))
if err != nil {
slog.Error("failed to create audit entry for handleAuthLoginEvent", "error", err)
slog.Error("failed to create audit entry for auth event", "error", err)
return
}
}
func (r *Recorder) handleInterfaceEvent(event domain.AuditEventWrapper[InterfaceEvent]) {
err := r.db.SaveAuditEntry(context.Background(), r.interfaceEventToAuditEntry(event))
if err != nil {
slog.Error("failed to create audit entry for interface event", "error", err)
return
}
}
func (r *Recorder) handlePeerEvent(event domain.AuditEventWrapper[PeerEvent]) {
err := r.db.SaveAuditEntry(context.Background(), r.peerEventToAuditEntry(event))
if err != nil {
slog.Error("failed to create audit entry for peer event", "error", err)
return
}
}
func (r *Recorder) authEventToAuditEntry(event domain.AuditEventWrapper[AuthEvent]) *domain.AuditEntry {
contextUser := domain.GetUserInfo(event.Ctx)
e := domain.AuditEntry{
CreatedAt: time.Now(),
Severity: domain.AuditSeverityLevelLow,
ContextUser: contextUser.UserId(),
Origin: fmt.Sprintf("auth: %s", event.Source),
Message: fmt.Sprintf("%s logged in", event.Event.Username),
}
if event.Event.Error != "" {
e.Severity = domain.AuditSeverityLevelHigh
e.Message = fmt.Sprintf("%s failed to login: %s", event.Event.Username, event.Event.Error)
}
return &e
}
func (r *Recorder) interfaceEventToAuditEntry(event domain.AuditEventWrapper[InterfaceEvent]) *domain.AuditEntry {
contextUser := domain.GetUserInfo(event.Ctx)
e := domain.AuditEntry{
CreatedAt: time.Now(),
Severity: domain.AuditSeverityLevelLow,
ContextUser: contextUser.UserId(),
Origin: fmt.Sprintf("interface: %s", event.Event.Action),
}
switch event.Event.Action {
case "save":
e.Message = fmt.Sprintf("%s updated", event.Event.Interface.Identifier)
default:
e.Message = fmt.Sprintf("%s: unknown action", event.Event.Interface.Identifier)
}
return &e
}
func (r *Recorder) peerEventToAuditEntry(event domain.AuditEventWrapper[PeerEvent]) *domain.AuditEntry {
contextUser := domain.GetUserInfo(event.Ctx)
e := domain.AuditEntry{
CreatedAt: time.Now(),
Severity: domain.AuditSeverityLevelLow,
ContextUser: contextUser.UserId(),
Origin: fmt.Sprintf("peer: %s", event.Event.Action),
}
switch event.Event.Action {
case "save":
e.Message = fmt.Sprintf("%s updated", event.Event.Peer.Identifier)
default:
e.Message = fmt.Sprintf("%s: unknown action", event.Event.Peer.Identifier)
}
return &e
}

View File

@ -17,6 +17,7 @@ import (
"golang.org/x/oauth2"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/audit"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
@ -245,10 +246,24 @@ func (a *Authenticator) PlainLogin(ctx context.Context, username, password strin
user, err := a.passwordAuthentication(ctx, domain.UserIdentifier(username), password)
if err != nil {
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "plain",
Event: audit.AuthEvent{
Username: username, Error: err.Error(),
},
})
return nil, fmt.Errorf("login failed: %w", err)
}
a.bus.Publish(app.TopicAuthLogin, user.Identifier)
a.bus.Publish(app.TopicAuditLoginSuccess, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "plain",
Event: audit.AuthEvent{
Username: string(user.Identifier),
},
})
return user, nil
}
@ -405,14 +420,37 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
user, err := a.processUserInfo(ctx, userInfo, domain.UserSourceOauth, oauthProvider.GetName(),
oauthProvider.RegistrationEnabled())
if err != nil {
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "oauth " + providerId,
Event: audit.AuthEvent{
Username: string(userInfo.Identifier),
Error: err.Error(),
},
})
return nil, fmt.Errorf("unable to process user information: %w", err)
}
if user.IsLocked() || user.IsDisabled() {
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "oauth " + providerId,
Event: audit.AuthEvent{
Username: string(user.Identifier),
Error: "user is locked",
},
})
return nil, errors.New("user is locked")
}
a.bus.Publish(app.TopicAuthLogin, user.Identifier)
a.bus.Publish(app.TopicAuditLoginSuccess, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "oauth " + providerId,
Event: audit.AuthEvent{
Username: string(user.Identifier),
},
})
return user, nil
}

View File

@ -13,3 +13,9 @@ const TopicRouteRemove = "route:remove"
const TopicInterfaceUpdated = "interface:updated"
const TopicPeerInterfaceUpdated = "peer:interface:updated"
const TopicPeerIdentifierUpdated = "peer:identifier:updated"
const TopicAuditLoginSuccess = "audit:login:success"
const TopicAuditLoginFailed = "audit:login:failed"
const TopicAuditInterfaceChanged = "audit:interface:changed"
const TopicAuditPeerChanged = "audit:peer:changed"

View File

@ -10,6 +10,7 @@ import (
"time"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/audit"
"github.com/h44z/wg-portal/internal/domain"
)
@ -549,6 +550,13 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
}
m.bus.Publish(app.TopicInterfaceUpdated, iface)
m.bus.Publish(app.TopicAuditInterfaceChanged, domain.AuditEventWrapper[audit.InterfaceEvent]{
Ctx: ctx,
Event: audit.InterfaceEvent{
Interface: *iface,
Action: "save",
},
})
return iface, nil
}

View File

@ -8,6 +8,7 @@ import (
"time"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/audit"
"github.com/h44z/wg-portal/internal/domain"
)
@ -426,6 +427,15 @@ func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error {
return fmt.Errorf("save failure for peer %s: %w", peer.Identifier, err)
}
// publish event
m.bus.Publish(app.TopicAuditPeerChanged, domain.AuditEventWrapper[audit.PeerEvent]{
Ctx: ctx,
Event: audit.PeerEvent{
Action: "save",
Peer: *peer,
},
})
interfaces[peer.InterfaceIdentifier] = struct{}{}
}

View File

@ -1,18 +1,30 @@
package domain
import "time"
import (
"context"
"time"
)
type AuditSeverityLevel string
const AuditSeverityLevelLow AuditSeverityLevel = "low"
const AuditSeverityLevelHigh AuditSeverityLevel = "high"
type AuditEntry struct {
UniqueId uint64 `gorm:"primaryKey;autoIncrement:true;column:id"`
CreatedAt time.Time `gorm:"column:created_at;index:idx_au_created"`
ContextUser string `gorm:"column:context_user;index:idx_au_context_user"`
Severity AuditSeverityLevel `gorm:"column:severity;index:idx_au_severity"`
Origin string `gorm:"column:origin"` // origin: for example user auth, stats, ...
Message string `gorm:"column:message"`
}
type AuditEventWrapper[T any] struct {
Ctx context.Context
Source string
Event T
}