API - CRUD for peers, interfaces and users (#340)

Public REST API implementation to handle peers, interfaces and users. It also includes some simple provisioning endpoints.

The Swagger API documentation is available under /api/v1/doc.html
This commit is contained in:
h44z 2025-01-11 18:44:55 +01:00 committed by GitHub
parent ad267ed0a8
commit d596f578f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 11028 additions and 274 deletions

185
README.md
View File

@ -38,7 +38,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL or Post
* Peer Expiry Feature
* Handle route and DNS settings like wg-quick does
* Exposes Prometheus [metrics](#metrics)
* ~~REST API for management and client deployment~~ (coming soon)
* REST API for management and client deployment
![Screenshot](screenshot.png)
@ -55,97 +55,98 @@ By default, WireGuard Portal uses a SQLite database. The database is stored in *
### Configuration Options
The following configuration options are available:
| configuration key | parent key | default_value | description |
|----------------------------------|------------|--------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|
| admin_user | core | admin@wgportal.local | The administrator user. This user will be created as default admin if it does not yet exist. |
| admin_password | core | wgportal | The administrator password. If unchanged, a random password will be set on first startup. |
| editable_keys | core | true | Allow to edit key-pairs in the UI. |
| create_default_peer | core | false | If an LDAP user logs in for the first time and has no peers associated, a new WireGuard peer will be created for all server interfaces. |
| create_default_peer_on_creation | core | false | If an LDAP user is created (e.g. through LDAP sync), a new WireGuard peer will be created for all server interfaces. |
| re_enable_peer_after_user_enable | core | true | Re-enable all peers that were previously disabled due to a user disable action. |
| delete_peer_after_user_deleted | core | false | Delete all linked peers if a user gets disabled. Otherwise the peers only get disabled. |
| self_provisioning_allowed | core | false | Allow registered users to automatically create peers via their profile page. |
| import_existing | core | true | Import existing WireGuard interfaces and peers into WireGuard Portal. |
| restore_state | core | true | Restore the WireGuard interface state after WireGuard Portal has started. |
| log_level | advanced | info | The loglevel, can be one of: trace, debug, info, warn, error. |
| log_pretty | advanced | false | Uses pretty, colorized log messages. |
| log_json | advanced | false | Logs in JSON format. |
| start_listen_port | advanced | 51820 | The first port number that will be used as listening port for new interfaces. |
| start_cidr_v4 | advanced | 10.11.12.0/24 | The first IPv4 subnet that will be used for new interfaces. |
| start_cidr_v6 | advanced | fdfd:d3ad:c0de:1234::0/64 | The first IPv6 subnet that will be used for new interfaces. |
| use_ip_v6 | advanced | true | Enable IPv6 support. |
| config_storage_path | advanced | | If a wg-quick style configuration should be stored to the filesystem, specify a storage directory. |
| expiry_check_interval | advanced | 15m | The interval after which existing peers will be checked if they expired. |
| rule_prio_offset | advanced | 20000 | The default offset for ip route rule priorities. |
| route_table_offset | advanced | 20000 | The default offset for ip route table id's. |
| use_ping_checks | statistics | true | If enabled, peers will be pinged periodically to check if they are still connected. |
| ping_check_workers | statistics | 10 | Number of parallel ping checks that will be executed. |
| ping_unprivileged | statistics | false | If set to false, the ping checks will run without root permissions (BETA). |
| ping_check_interval | statistics | 1m | The interval time between two ping check runs. |
| data_collection_interval | statistics | 1m | The interval between the data collection cycles. |
| collect_interface_data | statistics | true | A flag to enable interface data collection like bytes sent and received. |
| collect_peer_data | statistics | true | A flag to enable peer data collection like bytes sent and received, last handshake and remote endpoint address. |
| collect_audit_data | statistics | true | If enabled, some events, like portal logins, will be logged to the database. |
| listening_address | statistics | :8787 | The listening address of the Prometheus metric server. |
| host | mail | 127.0.0.1 | The mail-server address. |
| port | mail | 25 | The mail-server SMTP port. |
| encryption | mail | none | SMTP encryption type, allowed values: none, tls, starttls. |
| cert_validation | mail | false | Validate the mail server certificate (if encryption tls is used). |
| username | mail | | The SMTP user name. |
| password | mail | | The SMTP password. |
| auth_type | mail | plain | SMTP authentication type, allowed values: plain, login, crammd5. |
| from | mail | Wireguard Portal <noreply@wireguard.local> | The address that is used to send mails. |
| link_only | mail | false | Only send links to WireGuard Portal instead of the full configuration. |
| oidc | auth | Empty Array - no providers configured | A list of OpenID Connect providers. See auth/oidc properties to setup a new provider. |
| oauth | auth | Empty Array - no providers configured | A list of plain OAuth providers. See auth/oauth properties to setup a new provider. |
| ldap | auth | Empty Array - no providers configured | A list of LDAP providers. See auth/ldap properties to setup a new provider. |
| provider_name | auth/oidc | | A unique provider name. This name must be unique throughout all authentication providers (even other types). |
| display_name | auth/oidc | | The display name is shown at the login page (the login button). |
| base_url | auth/oidc | | The base_url is the URL identifier for the service. For example: "https://accounts.google.com". |
| client_id | auth/oidc | | The OAuth client id. |
| client_secret | auth/oidc | | The OAuth client secret. |
| extra_scopes | auth/oidc | | Extra scopes that should be used in the OpenID Connect authentication flow. |
| field_map | auth/oidc | | Mapping of user fields. Internal fields: user_identifier, email, firstname, lastname, phone, department and is_admin. |
| registration_enabled | auth/oidc | | If registration is enabled, new user accounts will created in WireGuard Portal. |
| provider_name | auth/oauth | | A unique provider name. This name must be unique throughout all authentication providers (even other types). |
| display_name | auth/oauth | | The display name is shown at the login page (the login button). |
| client_id | auth/oauth | | The OAuth client id. |
| client_secret | auth/oauth | | The OAuth client secret. |
| auth_url | auth/oauth | | The URL for the authentication endpoint. |
| token_url | auth/oauth | | The URL for the token endpoint. |
| user_info_url | auth/oauth | | The URL for the user information endpoint. |
| scopes | auth/oauth | | OAuth scopes. |
| field_map | auth/oauth | | Mapping of user fields. Internal fields: user_identifier, email, firstname, lastname, phone, department and is_admin. |
| registration_enabled | auth/oauth | | If registration is enabled, new user accounts will created in WireGuard Portal. |
| url | auth/ldap | | The LDAP server url. For example: ldap://srv-ad01.company.local:389 |
| start_tls | auth/ldap | | Use STARTTLS to encrypt LDAP requests. |
| cert_validation | auth/ldap | | Validate the LDAP server certificate. |
| tls_certificate_path | auth/ldap | | A path to the TLS certificate. |
| tls_key_path | auth/ldap | | A path to the TLS key. |
| base_dn | auth/ldap | | The base DN for searching users. For example: DC=COMPANY,DC=LOCAL |
| bind_user | auth/ldap | | The bind user. For example: company\\ldap_wireguard |
| bind_pass | auth/ldap | | The bind password. |
| field_map | auth/ldap | | Mapping of user fields. Internal fields: user_identifier, email, firstname, lastname, phone, department and memberof. |
| login_filter | auth/ldap | | LDAP filters for users that should be allowed to log in. {{login_identifier}} will be replaced with the login username. |
| admin_group | auth/ldap | | Users in this group are marked as administrators. |
| disable_missing | auth/ldap | | If synchronization is enabled, missing LDAP users will be disabled in WireGuard Portal. |
| sync_filter | auth/ldap | | LDAP filters for users that should be synchronized to WireGuard Portal. |
| sync_interval | auth/ldap | | The time interval after which users will be synchronized from LDAP. Empty value or `0` disables synchronization. |
| registration_enabled | auth/ldap | | If registration is enabled, new user accounts will created in WireGuard Portal. |
| debug | database | false | Debug database statements (log each statement). |
| slow_query_threshold | database | | A threshold for slow database queries. If the threshold is exceeded, a warning message will be logged. |
| type | database | sqlite | The database type. Allowed values: sqlite, mssql, mysql or postgres. |
| dsn | database | data/sqlite.db | The database DSN. For example: user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local |
| request_logging | web | false | Log all HTTP requests. |
| external_url | web | http://localhost:8888 | The URL where a client can access WireGuard Portal. |
| listening_address | web | :8888 | The listening port of the web server. |
| session_identifier | web | wgPortalSession | The session identifier for the web frontend. |
| session_secret | web | very_secret | The session secret for the web frontend. |
| csrf_secret | web | extremely_secret | The CSRF secret. |
| site_title | web | WireGuard Portal | The title that is shown in the web frontend. |
| site_company_name | web | WireGuard Portal | The company name that is shown at the bottom of the web frontend. |
| cert_file | web | | (Optional) Path to the TLS certificate file |
| key_file | web | | (Optional) Path to the TLS certificate key file |
| configuration key | parent key | default_value | description |
|----------------------------------|------------|--------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|
| admin_user | core | admin@wgportal.local | The administrator user. This user will be created as default admin if it does not yet exist. |
| admin_password | core | wgportal | The administrator password. If unchanged, a random password will be set on first startup. |
| editable_keys | core | true | Allow to edit key-pairs in the UI. |
| create_default_peer | core | false | If an LDAP user logs in for the first time and has no peers associated, a new WireGuard peer will be created for all server interfaces. |
| create_default_peer_on_creation | core | false | If an LDAP user is created (e.g. through LDAP sync), a new WireGuard peer will be created for all server interfaces. |
| re_enable_peer_after_user_enable | core | true | Re-enable all peers that were previously disabled due to a user disable action. |
| delete_peer_after_user_deleted | core | false | Delete all linked peers if a user gets disabled. Otherwise the peers only get disabled. |
| self_provisioning_allowed | core | false | Allow registered users to automatically create peers via their profile page. |
| import_existing | core | true | Import existing WireGuard interfaces and peers into WireGuard Portal. |
| restore_state | core | true | Restore the WireGuard interface state after WireGuard Portal has started. |
| log_level | advanced | info | The loglevel, can be one of: trace, debug, info, warn, error. |
| log_pretty | advanced | false | Uses pretty, colorized log messages. |
| log_json | advanced | false | Logs in JSON format. |
| start_listen_port | advanced | 51820 | The first port number that will be used as listening port for new interfaces. |
| start_cidr_v4 | advanced | 10.11.12.0/24 | The first IPv4 subnet that will be used for new interfaces. |
| start_cidr_v6 | advanced | fdfd:d3ad:c0de:1234::0/64 | The first IPv6 subnet that will be used for new interfaces. |
| use_ip_v6 | advanced | true | Enable IPv6 support. |
| config_storage_path | advanced | | If a wg-quick style configuration should be stored to the filesystem, specify a storage directory. |
| expiry_check_interval | advanced | 15m | The interval after which existing peers will be checked if they expired. |
| rule_prio_offset | advanced | 20000 | The default offset for ip route rule priorities. |
| route_table_offset | advanced | 20000 | The default offset for ip route table id's. |
| api_admin_only | advanced | true | This flag specifies if the public REST API is available to administrators only. The API Swagger documentation is available under /api/v1/doc.html |
| use_ping_checks | statistics | true | If enabled, peers will be pinged periodically to check if they are still connected. |
| ping_check_workers | statistics | 10 | Number of parallel ping checks that will be executed. |
| ping_unprivileged | statistics | false | If set to false, the ping checks will run without root permissions (BETA). |
| ping_check_interval | statistics | 1m | The interval time between two ping check runs. |
| data_collection_interval | statistics | 1m | The interval between the data collection cycles. |
| collect_interface_data | statistics | true | A flag to enable interface data collection like bytes sent and received. |
| collect_peer_data | statistics | true | A flag to enable peer data collection like bytes sent and received, last handshake and remote endpoint address. |
| collect_audit_data | statistics | true | If enabled, some events, like portal logins, will be logged to the database. |
| listening_address | statistics | :8787 | The listening address of the Prometheus metric server. |
| host | mail | 127.0.0.1 | The mail-server address. |
| port | mail | 25 | The mail-server SMTP port. |
| encryption | mail | none | SMTP encryption type, allowed values: none, tls, starttls. |
| cert_validation | mail | false | Validate the mail server certificate (if encryption tls is used). |
| username | mail | | The SMTP user name. |
| password | mail | | The SMTP password. |
| auth_type | mail | plain | SMTP authentication type, allowed values: plain, login, crammd5. |
| from | mail | Wireguard Portal <noreply@wireguard.local> | The address that is used to send mails. |
| link_only | mail | false | Only send links to WireGuard Portal instead of the full configuration. |
| oidc | auth | Empty Array - no providers configured | A list of OpenID Connect providers. See auth/oidc properties to setup a new provider. |
| oauth | auth | Empty Array - no providers configured | A list of plain OAuth providers. See auth/oauth properties to setup a new provider. |
| ldap | auth | Empty Array - no providers configured | A list of LDAP providers. See auth/ldap properties to setup a new provider. |
| provider_name | auth/oidc | | A unique provider name. This name must be unique throughout all authentication providers (even other types). |
| display_name | auth/oidc | | The display name is shown at the login page (the login button). |
| base_url | auth/oidc | | The base_url is the URL identifier for the service. For example: "https://accounts.google.com". |
| client_id | auth/oidc | | The OAuth client id. |
| client_secret | auth/oidc | | The OAuth client secret. |
| extra_scopes | auth/oidc | | Extra scopes that should be used in the OpenID Connect authentication flow. |
| field_map | auth/oidc | | Mapping of user fields. Internal fields: user_identifier, email, firstname, lastname, phone, department and is_admin. |
| registration_enabled | auth/oidc | | If registration is enabled, new user accounts will created in WireGuard Portal. |
| provider_name | auth/oauth | | A unique provider name. This name must be unique throughout all authentication providers (even other types). |
| display_name | auth/oauth | | The display name is shown at the login page (the login button). |
| client_id | auth/oauth | | The OAuth client id. |
| client_secret | auth/oauth | | The OAuth client secret. |
| auth_url | auth/oauth | | The URL for the authentication endpoint. |
| token_url | auth/oauth | | The URL for the token endpoint. |
| user_info_url | auth/oauth | | The URL for the user information endpoint. |
| scopes | auth/oauth | | OAuth scopes. |
| field_map | auth/oauth | | Mapping of user fields. Internal fields: user_identifier, email, firstname, lastname, phone, department and is_admin. |
| registration_enabled | auth/oauth | | If registration is enabled, new user accounts will created in WireGuard Portal. |
| url | auth/ldap | | The LDAP server url. For example: ldap://srv-ad01.company.local:389 |
| start_tls | auth/ldap | | Use STARTTLS to encrypt LDAP requests. |
| cert_validation | auth/ldap | | Validate the LDAP server certificate. |
| tls_certificate_path | auth/ldap | | A path to the TLS certificate. |
| tls_key_path | auth/ldap | | A path to the TLS key. |
| base_dn | auth/ldap | | The base DN for searching users. For example: DC=COMPANY,DC=LOCAL |
| bind_user | auth/ldap | | The bind user. For example: company\\ldap_wireguard |
| bind_pass | auth/ldap | | The bind password. |
| field_map | auth/ldap | | Mapping of user fields. Internal fields: user_identifier, email, firstname, lastname, phone, department and memberof. |
| login_filter | auth/ldap | | LDAP filters for users that should be allowed to log in. {{login_identifier}} will be replaced with the login username. |
| admin_group | auth/ldap | | Users in this group are marked as administrators. |
| disable_missing | auth/ldap | | If synchronization is enabled, missing LDAP users will be disabled in WireGuard Portal. |
| sync_filter | auth/ldap | | LDAP filters for users that should be synchronized to WireGuard Portal. |
| sync_interval | auth/ldap | | The time interval after which users will be synchronized from LDAP. Empty value or `0` disables synchronization. |
| registration_enabled | auth/ldap | | If registration is enabled, new user accounts will created in WireGuard Portal. |
| debug | database | false | Debug database statements (log each statement). |
| slow_query_threshold | database | | A threshold for slow database queries. If the threshold is exceeded, a warning message will be logged. |
| type | database | sqlite | The database type. Allowed values: sqlite, mssql, mysql or postgres. |
| dsn | database | data/sqlite.db | The database DSN. For example: user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local |
| request_logging | web | false | Log all HTTP requests. |
| external_url | web | http://localhost:8888 | The URL where a client can access WireGuard Portal. |
| listening_address | web | :8888 | The listening port of the web server. |
| session_identifier | web | wgPortalSession | The session identifier for the web frontend. |
| session_secret | web | very_secret | The session secret for the web frontend. |
| csrf_secret | web | extremely_secret | The CSRF secret. |
| site_title | web | WireGuard Portal | The title that is shown in the web frontend. |
| site_company_name | web | WireGuard Portal | The company name that is shown at the bottom of the web frontend. |
| cert_file | web | | (Optional) Path to the TLS certificate file |
| key_file | web | | (Optional) Path to the TLS certificate key file |
## Upgrading from V1

View File

@ -20,7 +20,7 @@ func main() {
}
apiBasePath := filepath.Join(wd, "/internal/app/api")
apis := []string{"v0"}
apis := []string{"v0", "v1"}
hasError := false
for _, apiVersion := range apis {

View File

@ -9,6 +9,8 @@ import (
"github.com/h44z/wg-portal/internal/app/api/core"
handlersV0 "github.com/h44z/wg-portal/internal/app/api/v0/handlers"
backendV1 "github.com/h44z/wg-portal/internal/app/api/v1/backend"
handlersV1 "github.com/h44z/wg-portal/internal/app/api/v1/handlers"
"github.com/h44z/wg-portal/internal/app/audit"
"github.com/h44z/wg-portal/internal/app/auth"
"github.com/h44z/wg-portal/internal/app/configfile"
@ -103,7 +105,23 @@ func main() {
apiFrontend := handlersV0.NewRestApi(cfg, backend)
webSrv, err := core.NewServer(cfg, apiFrontend)
apiV1BackendUsers := backendV1.NewUserService(cfg, userManager)
apiV1BackendPeers := backendV1.NewPeerService(cfg, wireGuardManager, userManager)
apiV1BackendInterfaces := backendV1.NewInterfaceService(cfg, wireGuardManager)
apiV1BackendProvisioning := backendV1.NewProvisioningService(cfg, userManager, wireGuardManager, cfgFileManager)
apiV1EndpointUsers := handlersV1.NewUserEndpoint(apiV1BackendUsers)
apiV1EndpointPeers := handlersV1.NewPeerEndpoint(apiV1BackendPeers)
apiV1EndpointInterfaces := handlersV1.NewInterfaceEndpoint(apiV1BackendInterfaces)
apiV1EndpointProvisioning := handlersV1.NewProvisioningEndpoint(apiV1BackendProvisioning)
apiV1 := handlersV1.NewRestApi(
userManager,
apiV1EndpointUsers,
apiV1EndpointPeers,
apiV1EndpointInterfaces,
apiV1EndpointProvisioning,
)
webSrv, err := core.NewServer(cfg, apiFrontend, apiV1)
internal.AssertNoError(err)
go metricsServer.Run(ctx)

View File

@ -90,6 +90,7 @@ const currentYear = ref(new Date().getFullYear())
href="#" role="button">{{ auth.User.Firstname }} {{ auth.User.Lastname }}</a>
<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>
<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

@ -88,6 +88,10 @@ function close() {
<td>{{ $t('modals.user-view.department') }}:</td>
<td>{{selectedUser.Department}}</td>
</tr>
<tr>
<td>{{ $t('modals.user-view.api-enabled') }}:</td>
<td>{{selectedUser.ApiEnabled}}</td>
</tr>
<tr v-if="selectedUser.Disabled">
<td>{{ $t('modals.user-view.disabled') }}:</td>
<td>{{selectedUser.DisabledReason}}</td>

View File

@ -146,6 +146,8 @@ export function freshUser() {
Locked: false,
LockedReason: "",
ApiEnabled: false,
PeerCount: 0
}
}

View File

@ -37,6 +37,7 @@
"users": "Benutzer",
"lang": "Sprache ändern",
"profile": "Mein Profil",
"settings": "Einstellungen",
"login": "Anmelden",
"logout": "Abmelden"
},
@ -167,6 +168,26 @@
"button-show-peer": "Show Peer",
"button-edit-peer": "Edit Peer"
},
"settings": {
"headline": "Einstellungen",
"abstract": "Hier finden Sie persönliche Einstellungen für WireGuard Portal.",
"api": {
"headline": "API Einstellungen",
"abstract": "Hier können Sie die RESTful API verwalten.",
"active-description": "Die API ist derzeit für Ihr Benutzerkonto aktiv. Alle API-Anfragen werden mit Basic Auth authentifiziert. Verwenden Sie zur Authentifizierung die folgenden Anmeldeinformationen.",
"inactive-description": "Die API ist derzeit inaktiv. Klicken Sie auf die Schaltfläche unten, um sie zu aktivieren.",
"user-label": "API Benutzername:",
"user-placeholder": "API Benutzer",
"token-label": "API Passwort:",
"token-placeholder": "API Token",
"token-created-label": "API-Zugriff gewährt seit: ",
"button-disable-title": "Deaktivieren Sie die API. Dadurch wird der aktuelle Token ungültig.",
"button-disable-text": "API deaktivieren",
"button-enable-title": "Aktivieren Sie die API, dadurch wird ein neuer Token generiert.",
"button-enable-text": "API aktivieren",
"api-link": "API Dokumentation"
}
},
"modals": {
"user-view": {
"headline": "User Account:",

View File

@ -37,6 +37,7 @@
"users": "Users",
"lang": "Toggle Language",
"profile": "My Profile",
"settings": "Settings",
"login": "Login",
"logout": "Logout"
},
@ -167,6 +168,26 @@
"button-show-peer": "Show Peer",
"button-edit-peer": "Edit Peer"
},
"settings": {
"headline": "Settings",
"abstract": "Here you can change your personal settings.",
"api": {
"headline": "API Settings",
"abstract": "Here you can configure the RESTful API settings.",
"active-description": "The API is currently active for your user account. All API requests are authenticated with Basic Auth. Use the following credentials for authentication.",
"inactive-description": "The API is currently inactive. Press the button below to activate it.",
"user-label": "API Username:",
"user-placeholder": "The API user",
"token-label": "API Password:",
"token-placeholder": "The API token",
"token-created-label": "API access granted at: ",
"button-disable-title": "Disable API, this will invalidate the current token.",
"button-disable-text": "Disable API",
"button-enable-title": "Enable API, this will generate a new token.",
"button-enable-text": "Enable API",
"api-link": "API Documentation"
}
},
"modals": {
"user-view": {
"headline": "User Account:",
@ -177,8 +198,9 @@
"email": "E-Mail",
"firstname": "Firstname",
"lastname": "Lastname",
"phone": "Phone number",
"phone": "Phone Number",
"department": "Department",
"api-enabled": "API Access",
"disabled": "Account Disabled",
"locked": "Account Locked",
"no-peers": "User has no associated peers.",

View File

@ -47,6 +47,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/ProfileView.vue')
},
{
path: '/settings',
name: 'settings',
// 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/SettingsView.vue')
}
],
linkActiveClass: "active",

View File

@ -116,6 +116,34 @@ export const profileStore = defineStore({
this.stats = statsResponse.Stats
this.statsEnabled = statsResponse.Enabled
},
async enableApi() {
this.fetching = true
let currentUser = authStore().user.Identifier
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/api/enable`)
.then(this.setUser)
.catch(error => {
this.setPeers([])
console.log("Failed to activate API for ", currentUser, ": ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to activate API!",
})
})
},
async disableApi() {
this.fetching = true
let currentUser = authStore().user.Identifier
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/api/disable`)
.then(this.setUser)
.catch(error => {
this.setPeers([])
console.log("Failed to deactivate API for ", currentUser, ": ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to deactivate API!",
})
})
},
async LoadPeers() {
this.fetching = true
let currentUser = authStore().user.Identifier

View File

@ -0,0 +1,77 @@
<script setup>
import PeerViewModal from "../components/PeerViewModal.vue";
import { onMounted, ref } 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()
const settings = settingsStore()
const auth = authStore()
onMounted(async () => {
await profile.LoadUser()
})
</script>
<template>
<div class="page-header">
<h1>{{ $t('settings.headline') }}</h1>
</div>
<p class="lead">{{ $t('settings.abstract') }}</p>
<div v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')">
<div class="bg-light p-5" v-if="profile.user.ApiToken">
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
<p class="lead">{{ $t('settings.api.abstract') }}</p>
<hr class="my-4">
<p>{{ $t('settings.api.active-description') }}</p>
<div class="row">
<div class="col-6">
<div class="form-group">
<label class="form-label mt-4">{{ $t('settings.api.user-label') }}</label>
<input v-model="profile.user.Identifier" class="form-control" :placeholder="$t('settings.api.user-placeholder')" type="text" readonly>
</div>
</div>
<div class="col-6">
<div class="form-group">
<label class="form-label mt-4">{{ $t('settings.api.token-label') }}</label>
<input v-model="profile.user.ApiToken" class="form-control" :placeholder="$t('settings.api.token-placeholder')" type="text" readonly>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="form-group">
<p class="form-label mt-4">{{ $t('settings.api.token-created-label') }} {{profile.user.ApiTokenCreated}}</p>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-6">
<button class="input-group-text btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching">
<i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }}
</button>
</div>
<div class="col-6">
<a href="/api/v1/doc.html" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
</div>
</div>
</div>
<div class="bg-light p-5" v-else>
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
<p class="lead">{{ $t('settings.api.abstract') }}</p>
<hr class="my-4">
<p>{{ $t('settings.api.inactive-description') }}</p>
<button class="input-group-text btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching">
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }}
</button>
</div>
</div>
</template>

View File

@ -698,6 +698,30 @@ func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domai
return &user, nil
}
func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
var users []domain.User
err := r.db.WithContext(ctx).Where("email = ?", email).Find(&users).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, err
}
if len(users) == 0 {
return nil, domain.ErrNotFound
}
if len(users) > 1 {
return nil, fmt.Errorf("found multiple users with email %s: %w", email, domain.ErrNotUnique)
}
user := users[0]
return &user, nil
}
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
var users []domain.User

View File

@ -1,8 +1,8 @@
{
"swagger": "2.0",
"info": {
"description": "WireGuard Portal API - a testing API endpoint",
"title": "WireGuard Portal API",
"description": "WireGuard Portal API - UI Endpoints",
"title": "WireGuard Portal SPA-UI API",
"contact": {
"name": "WireGuard Portal Developers",
"url": "https://github.com/h44z/wg-portal"
@ -175,6 +175,26 @@
}
}
},
"/config/settings": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Configuration"
],
"summary": "Get the frontend settings object.",
"operationId": "config_handleSettingsGet",
"responses": {
"200": {
"description": "The JavaScript contents",
"schema": {
"type": "string"
}
}
}
}
},
"/csrf": {
"get": {
"produces": [
@ -499,6 +519,91 @@
}
}
},
"/interface/{id}/apply-peer-defaults": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Interface"
],
"summary": "Apply all peer defaults to the available peers.",
"operationId": "interfaces_handleApplyPeerDefaultsPost",
"parameters": [
{
"type": "string",
"description": "The interface identifier",
"name": "id",
"in": "path",
"required": true
},
{
"description": "The interface data",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.Interface"
}
}
],
"responses": {
"204": {
"description": "No content if applying peer defaults was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/interface/{id}/save-config": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Interface"
],
"summary": "Save the interface configuration in wg-quick format to a file.",
"operationId": "interfaces_handleSaveConfigPost",
"parameters": [
{
"type": "string",
"description": "The interface identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No content if saving the configuration was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/now": {
"get": {
"description": "Nothing more to describe...",
@ -526,9 +631,50 @@
}
}
},
"/peer/config-mail": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Peer"
],
"summary": "Send peer configuration via email.",
"operationId": "peers_handleEmailPost",
"parameters": [
{
"description": "The peer mail request data",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.PeerMailRequest"
}
}
],
"responses": {
"204": {
"description": "No content if mail sending was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/peer/config-qr/{id}": {
"get": {
"produces": [
"image/png",
"application/json"
],
"tags": [
@ -536,11 +682,20 @@
],
"summary": "Get peer configuration as qr code.",
"operationId": "peers_handleQrCodeGet",
"parameters": [
{
"type": "string",
"description": "The peer identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
"type": "file"
}
},
"400": {
@ -568,6 +723,15 @@
],
"summary": "Get peer configuration as string.",
"operationId": "peers_handleConfigGet",
"parameters": [
{
"type": "string",
"description": "The peer identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
@ -634,6 +798,59 @@
}
}
},
"/peer/iface/{iface}/multiplenew": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Peer"
],
"summary": "Create multiple new peers for the given interface.",
"operationId": "peers_handleCreateMultiplePost",
"parameters": [
{
"type": "string",
"description": "The interface identifier",
"name": "iface",
"in": "path",
"required": true
},
{
"description": "The peer creation request data",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.MultiPeerRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.Peer"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/peer/iface/{iface}/new": {
"post": {
"produces": [
@ -725,6 +942,47 @@
}
}
},
"/peer/iface/{iface}/stats": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Peer"
],
"summary": "Get peer stats for the given interface.",
"operationId": "peers_handleStatsGet",
"parameters": [
{
"type": "string",
"description": "The interface identifier",
"name": "iface",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.PeerStats"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/peer/{id}": {
"get": {
"produces": [
@ -1041,6 +1299,70 @@
}
}
},
"/user/{id}/api/disable": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Disable the REST API for the given user.",
"operationId": "users_handleApiDisablePost",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/{id}/api/enable": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Enable the REST API for the given user.",
"operationId": "users_handleApiEnablePost",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/{id}/peers": {
"get": {
"produces": [
@ -1061,6 +1383,44 @@
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/{id}/stats": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Get peer stats for the given user.",
"operationId": "users_handleStatsGet",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.PeerStats"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
@ -1072,6 +1432,53 @@
}
},
"definitions": {
"model.ConfigOption-array_string": {
"type": "object",
"properties": {
"Overridable": {
"type": "boolean"
},
"Value": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"model.ConfigOption-int": {
"type": "object",
"properties": {
"Overridable": {
"type": "boolean"
},
"Value": {
"type": "integer"
}
}
},
"model.ConfigOption-string": {
"type": "object",
"properties": {
"Overridable": {
"type": "boolean"
},
"Value": {
"type": "string"
}
}
},
"model.ConfigOption-uint32": {
"type": "object",
"properties": {
"Overridable": {
"type": "boolean"
},
"Value": {
"type": "integer"
}
}
},
"model.Error": {
"type": "object",
"properties": {
@ -1083,25 +1490,11 @@
}
}
},
"model.Int32ConfigOption": {
"model.ExpiryDate": {
"type": "object",
"properties": {
"Overridable": {
"type": "boolean"
},
"Value": {
"type": "integer"
}
}
},
"model.IntConfigOption": {
"type": "object",
"properties": {
"Overridable": {
"type": "boolean"
},
"Value": {
"type": "integer"
"time.Time": {
"type": "string"
}
}
},
@ -1290,6 +1683,20 @@
}
}
},
"model.MultiPeerRequest": {
"type": "object",
"properties": {
"Identifiers": {
"type": "array",
"items": {
"type": "string"
}
},
"Suffix": {
"type": "string"
}
}
},
"model.Peer": {
"type": "object",
"properties": {
@ -1304,7 +1711,7 @@
"description": "all allowed ip subnets, comma seperated",
"allOf": [
{
"$ref": "#/definitions/model.StringSliceConfigOption"
"$ref": "#/definitions/model.ConfigOption-array_string"
}
]
},
@ -1328,7 +1735,7 @@
"description": "the dns server that should be set if the interface is up, comma separated",
"allOf": [
{
"$ref": "#/definitions/model.StringSliceConfigOption"
"$ref": "#/definitions/model.ConfigOption-array_string"
}
]
},
@ -1336,7 +1743,7 @@
"description": "the dns search option string that should be set if the interface is up, will be appended to DnsStr",
"allOf": [
{
"$ref": "#/definitions/model.StringSliceConfigOption"
"$ref": "#/definitions/model.ConfigOption-array_string"
}
]
},
@ -1344,7 +1751,7 @@
"description": "the endpoint address",
"allOf": [
{
"$ref": "#/definitions/model.StringConfigOption"
"$ref": "#/definitions/model.ConfigOption-string"
}
]
},
@ -1352,13 +1759,17 @@
"description": "the endpoint public key",
"allOf": [
{
"$ref": "#/definitions/model.StringConfigOption"
"$ref": "#/definitions/model.ConfigOption-string"
}
]
},
"ExpiresAt": {
"description": "expiry dates for peers",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/model.ExpiryDate"
}
]
},
"ExtraAllowedIPs": {
"description": "all allowed ip subnets on the server side, comma seperated",
@ -1371,7 +1782,7 @@
"description": "a firewall mark",
"allOf": [
{
"$ref": "#/definitions/model.Int32ConfigOption"
"$ref": "#/definitions/model.ConfigOption-uint32"
}
]
},
@ -1392,7 +1803,7 @@
"description": "the device MTU",
"allOf": [
{
"$ref": "#/definitions/model.IntConfigOption"
"$ref": "#/definitions/model.ConfigOption-int"
}
]
},
@ -1404,7 +1815,7 @@
"description": "the persistent keep-alive interval",
"allOf": [
{
"$ref": "#/definitions/model.IntConfigOption"
"$ref": "#/definitions/model.ConfigOption-int"
}
]
},
@ -1412,7 +1823,7 @@
"description": "action that is executed after the device is down",
"allOf": [
{
"$ref": "#/definitions/model.StringConfigOption"
"$ref": "#/definitions/model.ConfigOption-string"
}
]
},
@ -1420,7 +1831,7 @@
"description": "action that is executed after the device is up",
"allOf": [
{
"$ref": "#/definitions/model.StringConfigOption"
"$ref": "#/definitions/model.ConfigOption-string"
}
]
},
@ -1428,7 +1839,7 @@
"description": "action that is executed before the device is down",
"allOf": [
{
"$ref": "#/definitions/model.StringConfigOption"
"$ref": "#/definitions/model.ConfigOption-string"
}
]
},
@ -1436,7 +1847,7 @@
"description": "action that is executed before the device is up",
"allOf": [
{
"$ref": "#/definitions/model.StringConfigOption"
"$ref": "#/definitions/model.ConfigOption-string"
}
]
},
@ -1458,7 +1869,7 @@
"description": "the routing table",
"allOf": [
{
"$ref": "#/definitions/model.StringConfigOption"
"$ref": "#/definitions/model.ConfigOption-string"
}
]
},
@ -1468,6 +1879,66 @@
}
}
},
"model.PeerMailRequest": {
"type": "object",
"properties": {
"Identifiers": {
"type": "array",
"items": {
"type": "string"
}
},
"LinkOnly": {
"type": "boolean"
}
}
},
"model.PeerStatData": {
"type": "object",
"properties": {
"BytesReceived": {
"type": "integer"
},
"BytesTransmitted": {
"type": "integer"
},
"EndpointAddress": {
"type": "string"
},
"IsConnected": {
"type": "boolean"
},
"IsPingable": {
"type": "boolean"
},
"LastHandshake": {
"type": "string"
},
"LastPing": {
"type": "string"
},
"LastSessionStart": {
"type": "string"
}
}
},
"model.PeerStats": {
"type": "object",
"properties": {
"Enabled": {
"description": "peer stats tracking enabled",
"type": "boolean",
"example": true
},
"Stats": {
"description": "stats, map key = Peer identifier",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/model.PeerStatData"
}
}
}
},
"model.SessionInfo": {
"type": "object",
"properties": {
@ -1491,34 +1962,35 @@
}
}
},
"model.StringConfigOption": {
"model.Settings": {
"type": "object",
"properties": {
"Overridable": {
"ApiAdminOnly": {
"type": "boolean"
},
"Value": {
"type": "string"
}
}
},
"model.StringSliceConfigOption": {
"type": "object",
"properties": {
"Overridable": {
"MailLinkOnly": {
"type": "boolean"
},
"Value": {
"type": "array",
"items": {
"type": "string"
}
"PersistentConfigSupported": {
"type": "boolean"
},
"SelfProvisioning": {
"type": "boolean"
}
}
},
"model.User": {
"type": "object",
"properties": {
"ApiEnabled": {
"type": "boolean"
},
"ApiToken": {
"type": "string"
},
"ApiTokenCreated": {
"type": "string"
},
"Department": {
"type": "string"
},
@ -1545,6 +2017,14 @@
"Lastname": {
"type": "string"
},
"Locked": {
"description": "if this field is set, the user is locked",
"type": "boolean"
},
"LockedReason": {
"description": "the reason why the user has been locked",
"type": "string"
},
"Notes": {
"type": "string"
},

View File

@ -1,5 +1,35 @@
basePath: /api/v0
definitions:
model.ConfigOption-array_string:
properties:
Overridable:
type: boolean
Value:
items:
type: string
type: array
type: object
model.ConfigOption-int:
properties:
Overridable:
type: boolean
Value:
type: integer
type: object
model.ConfigOption-string:
properties:
Overridable:
type: boolean
Value:
type: string
type: object
model.ConfigOption-uint32:
properties:
Overridable:
type: boolean
Value:
type: integer
type: object
model.Error:
properties:
Code:
@ -7,19 +37,10 @@ definitions:
Message:
type: string
type: object
model.Int32ConfigOption:
model.ExpiryDate:
properties:
Overridable:
type: boolean
Value:
type: integer
type: object
model.IntConfigOption:
properties:
Overridable:
type: boolean
Value:
type: integer
time.Time:
type: string
type: object
model.Interface:
properties:
@ -160,6 +181,15 @@ definitions:
example: /auth/google/login
type: string
type: object
model.MultiPeerRequest:
properties:
Identifiers:
items:
type: string
type: array
Suffix:
type: string
type: object
model.Peer:
properties:
Addresses:
@ -169,7 +199,7 @@ definitions:
type: array
AllowedIPs:
allOf:
- $ref: '#/definitions/model.StringSliceConfigOption'
- $ref: '#/definitions/model.ConfigOption-array_string'
description: all allowed ip subnets, comma seperated
CheckAliveAddress:
description: optional ip address or DNS name that is used for ping checks
@ -185,25 +215,26 @@ definitions:
type: string
Dns:
allOf:
- $ref: '#/definitions/model.StringSliceConfigOption'
- $ref: '#/definitions/model.ConfigOption-array_string'
description: the dns server that should be set if the interface is up, comma
separated
DnsSearch:
allOf:
- $ref: '#/definitions/model.StringSliceConfigOption'
- $ref: '#/definitions/model.ConfigOption-array_string'
description: the dns search option string that should be set if the interface
is up, will be appended to DnsStr
Endpoint:
allOf:
- $ref: '#/definitions/model.StringConfigOption'
- $ref: '#/definitions/model.ConfigOption-string'
description: the endpoint address
EndpointPublicKey:
allOf:
- $ref: '#/definitions/model.StringConfigOption'
- $ref: '#/definitions/model.ConfigOption-string'
description: the endpoint public key
ExpiresAt:
allOf:
- $ref: '#/definitions/model.ExpiryDate'
description: expiry dates for peers
type: string
ExtraAllowedIPs:
description: all allowed ip subnets on the server side, comma seperated
items:
@ -211,7 +242,7 @@ definitions:
type: array
FirewallMark:
allOf:
- $ref: '#/definitions/model.Int32ConfigOption'
- $ref: '#/definitions/model.ConfigOption-uint32'
description: a firewall mark
Identifier:
description: peer unique identifier
@ -225,30 +256,30 @@ definitions:
type: string
Mtu:
allOf:
- $ref: '#/definitions/model.IntConfigOption'
- $ref: '#/definitions/model.ConfigOption-int'
description: the device MTU
Notes:
description: a note field for peers
type: string
PersistentKeepalive:
allOf:
- $ref: '#/definitions/model.IntConfigOption'
- $ref: '#/definitions/model.ConfigOption-int'
description: the persistent keep-alive interval
PostDown:
allOf:
- $ref: '#/definitions/model.StringConfigOption'
- $ref: '#/definitions/model.ConfigOption-string'
description: action that is executed after the device is down
PostUp:
allOf:
- $ref: '#/definitions/model.StringConfigOption'
- $ref: '#/definitions/model.ConfigOption-string'
description: action that is executed after the device is up
PreDown:
allOf:
- $ref: '#/definitions/model.StringConfigOption'
- $ref: '#/definitions/model.ConfigOption-string'
description: action that is executed before the device is down
PreUp:
allOf:
- $ref: '#/definitions/model.StringConfigOption'
- $ref: '#/definitions/model.ConfigOption-string'
description: action that is executed before the device is up
PresharedKey:
description: the pre-shared Key of the peer
@ -263,12 +294,52 @@ definitions:
type: string
RoutingTable:
allOf:
- $ref: '#/definitions/model.StringConfigOption'
- $ref: '#/definitions/model.ConfigOption-string'
description: the routing table
UserIdentifier:
description: the owner
type: string
type: object
model.PeerMailRequest:
properties:
Identifiers:
items:
type: string
type: array
LinkOnly:
type: boolean
type: object
model.PeerStatData:
properties:
BytesReceived:
type: integer
BytesTransmitted:
type: integer
EndpointAddress:
type: string
IsConnected:
type: boolean
IsPingable:
type: boolean
LastHandshake:
type: string
LastPing:
type: string
LastSessionStart:
type: string
type: object
model.PeerStats:
properties:
Enabled:
description: peer stats tracking enabled
example: true
type: boolean
Stats:
additionalProperties:
$ref: '#/definitions/model.PeerStatData'
description: stats, map key = Peer identifier
type: object
type: object
model.SessionInfo:
properties:
IsAdmin:
@ -284,24 +355,25 @@ definitions:
UserLastname:
type: string
type: object
model.StringConfigOption:
model.Settings:
properties:
Overridable:
ApiAdminOnly:
type: boolean
Value:
type: string
type: object
model.StringSliceConfigOption:
properties:
Overridable:
MailLinkOnly:
type: boolean
PersistentConfigSupported:
type: boolean
SelfProvisioning:
type: boolean
Value:
items:
type: string
type: array
type: object
model.User:
properties:
ApiEnabled:
type: boolean
ApiToken:
type: string
ApiTokenCreated:
type: string
Department:
type: string
Disabled:
@ -320,6 +392,12 @@ definitions:
type: boolean
Lastname:
type: string
Locked:
description: if this field is set, the user is locked
type: boolean
LockedReason:
description: the reason why the user has been locked
type: string
Notes:
type: string
Password:
@ -337,8 +415,8 @@ info:
contact:
name: WireGuard Portal Developers
url: https://github.com/h44z/wg-portal
description: WireGuard Portal API - a testing API endpoint
title: WireGuard Portal API
description: WireGuard Portal API - UI Endpoints
title: WireGuard Portal SPA-UI API
version: "0.0"
paths:
/auth/{provider}/callback:
@ -448,6 +526,19 @@ paths:
summary: Get the dynamic frontend configuration javascript.
tags:
- Configuration
/config/settings:
get:
operationId: config_handleSettingsGet
produces:
- application/json
responses:
"200":
description: The JavaScript contents
schema:
type: string
summary: Get the frontend settings object.
tags:
- Configuration
/csrf:
get:
operationId: base_handleCsrfGet
@ -536,6 +627,62 @@ paths:
summary: Update the interface record.
tags:
- Interface
/interface/{id}/apply-peer-defaults:
post:
operationId: interfaces_handleApplyPeerDefaultsPost
parameters:
- description: The interface identifier
in: path
name: id
required: true
type: string
- description: The interface data
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.Interface'
produces:
- application/json
responses:
"204":
description: No content if applying peer defaults was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Apply all peer defaults to the available peers.
tags:
- Interface
/interface/{id}/save-config:
post:
operationId: interfaces_handleSaveConfigPost
parameters:
- description: The interface identifier
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"204":
description: No content if saving the configuration was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Save the interface configuration in wg-quick format to a file.
tags:
- Interface
/interface/all:
get:
operationId: interfaces_handleAllGet
@ -762,16 +909,49 @@ paths:
summary: Update the given peer record.
tags:
- Peer
/peer/config-mail:
post:
operationId: peers_handleEmailPost
parameters:
- description: The peer mail request data
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.PeerMailRequest'
produces:
- application/json
responses:
"204":
description: No content if mail sending was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Send peer configuration via email.
tags:
- Peer
/peer/config-qr/{id}:
get:
operationId: peers_handleQrCodeGet
parameters:
- description: The peer identifier
in: path
name: id
required: true
type: string
produces:
- image/png
- application/json
responses:
"200":
description: OK
schema:
type: string
type: file
"400":
description: Bad Request
schema:
@ -786,6 +966,12 @@ paths:
/peer/config/{id}:
get:
operationId: peers_handleConfigGet
parameters:
- description: The peer identifier
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
@ -833,6 +1019,41 @@ paths:
summary: Get peers for the given interface.
tags:
- Peer
/peer/iface/{iface}/multiplenew:
post:
operationId: peers_handleCreateMultiplePost
parameters:
- description: The interface identifier
in: path
name: iface
required: true
type: string
- description: The peer creation request data
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.MultiPeerRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.Peer'
type: array
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Create multiple new peers for the given interface.
tags:
- Peer
/peer/iface/{iface}/new:
post:
operationId: peers_handleCreatePost
@ -893,6 +1114,33 @@ paths:
summary: Prepare a new peer for the given interface.
tags:
- Peer
/peer/iface/{iface}/stats:
get:
operationId: peers_handleStatsGet
parameters:
- description: The interface identifier
in: path
name: iface
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.PeerStats'
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Get peer stats for the given interface.
tags:
- Peer
/user/{id}:
delete:
operationId: users_handleDelete
@ -972,6 +1220,48 @@ paths:
summary: Update the user record.
tags:
- Users
/user/{id}/api/disable:
post:
operationId: users_handleApiDisablePost
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.User'
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Disable the REST API for the given user.
tags:
- Users
/user/{id}/api/enable:
post:
operationId: users_handleApiEnablePost
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.User'
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Enable the REST API for the given user.
tags:
- Users
/user/{id}/peers:
get:
operationId: users_handlePeersGet
@ -984,6 +1274,10 @@ paths:
items:
$ref: '#/definitions/model.Peer'
type: array
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
@ -991,6 +1285,27 @@ paths:
summary: Get peers for the given user.
tags:
- Users
/user/{id}/stats:
get:
operationId: users_handleStatsGet
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.PeerStats'
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Get peer stats for the given user.
tags:
- Users
/user/all:
get:
operationId: users_handleAllGet

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -8,10 +8,16 @@
<rapi-doc
spec-url="{{ $.ApiSpecUrl }}"
theme="dark"
render-style="focused"
allow-server-selection="false"
allow-authentication="false"
allow-authentication="true"
load-fonts="false"
schema-style="table"
schema-expand-level="1"
default-schema-tab="model"
fill-request-fields-with-example="true"
show-method-in-nav-bar="as-colored-block"
show-components="true"
allow-spec-url-load="false"
allow-spec-file-load="false"
allow-spec-file-download="true"

View File

@ -88,6 +88,8 @@ func NewServer(cfg *config.Config, endpoints ...ApiEndpointSetupFunc) (*Server,
s.server.StaticFS("/doc", http.FS(fsMust(fs.Sub(apiStatics, "assets/doc"))))
// Setup routes
s.server.UseRawPath = true
s.server.UnescapePathValues = true
s.setupRoutes(endpoints...)
s.setupFrontendRoutes()

View File

@ -1,6 +1,9 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-contrib/cors"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/memstore"
@ -10,8 +13,6 @@ import (
"github.com/h44z/wg-portal/internal/app/api/v0/model"
"github.com/h44z/wg-portal/internal/config"
csrf "github.com/utrack/gin-csrf"
"net/http"
"strings"
)
type handler interface {
@ -20,12 +21,12 @@ type handler interface {
}
// To compile the API documentation use the
// build_tool
// command that can be found in the $PROJECT_ROOT/internal/ports/api/build_tool directory.
// api_build_tool
// command that can be found in the $PROJECT_ROOT/cmd/api_build_tool directory.
// @title WireGuard Portal API
// @title WireGuard Portal SPA-UI API
// @version 0.0
// @description WireGuard Portal API - a testing API endpoint
// @description WireGuard Portal API - UI Endpoints
// @contact.name WireGuard Portal Developers
// @contact.url https://github.com/h44z/wg-portal

View File

@ -4,13 +4,14 @@ import (
"bytes"
"embed"
"fmt"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/api/v0/model"
"html/template"
"net"
"net/http"
"net/url"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/api/v0/model"
)
//go:embed frontend_config.js.gotpl
@ -63,7 +64,8 @@ func (e configEndpoint) handleConfigJsGet() gin.HandlerFunc {
if err == nil {
host, port, _ = net.SplitHostPort(parsedReferer.Host)
}
backendUrl = fmt.Sprintf("http://%s:%s/api/v0", host, port) // override if request comes from frontend started with npm run dev
backendUrl = fmt.Sprintf("http://%s:%s/api/v0", host,
port) // override if request comes from frontend started with npm run dev
}
buf := &bytes.Buffer{}
err := e.tpl.ExecuteTemplate(buf, "frontend_config.js.gotpl", gin.H{
@ -96,6 +98,7 @@ func (e configEndpoint) handleSettingsGet() gin.HandlerFunc {
MailLinkOnly: e.app.Config.Mail.LinkOnly,
PersistentConfigSupported: e.app.Config.Advanced.ConfigStoragePath != "",
SelfProvisioning: e.app.Config.Core.SelfProvisioningAllowed,
ApiAdminOnly: e.app.Config.Advanced.ApiAdminOnly,
})
}
}

View File

@ -1,12 +1,13 @@
package handlers
import (
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/api/v0/model"
"github.com/h44z/wg-portal/internal/domain"
"io"
"net/http"
)
type peerEndpoint struct {
@ -57,7 +58,8 @@ func (e peerEndpoint) handleAllGet() gin.HandlerFunc {
_, peers, err := e.app.GetInterfaceAndPeers(ctx, domain.InterfaceIdentifier(interfaceId))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
@ -88,7 +90,8 @@ func (e peerEndpoint) handleSingleGet() gin.HandlerFunc {
peer, err := e.app.GetPeer(ctx, domain.PeerIdentifier(peerId))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
@ -119,7 +122,8 @@ func (e peerEndpoint) handlePrepareGet() gin.HandlerFunc {
peer, err := e.app.PreparePeer(ctx, domain.InterfaceIdentifier(interfaceId))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
@ -163,7 +167,8 @@ func (e peerEndpoint) handleCreatePost() gin.HandlerFunc {
newPeer, err := e.app.CreatePeer(ctx, model.NewDomainPeer(&p))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
@ -200,9 +205,11 @@ func (e peerEndpoint) handleCreateMultiplePost() gin.HandlerFunc {
return
}
newPeers, err := e.app.CreateMultiplePeers(ctx, domain.InterfaceIdentifier(interfaceId), model.NewDomainPeerCreationRequest(&req))
newPeers, err := e.app.CreateMultiplePeers(ctx, domain.InterfaceIdentifier(interfaceId),
model.NewDomainPeerCreationRequest(&req))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
@ -246,7 +253,8 @@ func (e peerEndpoint) handleUpdatePut() gin.HandlerFunc {
updatedPeer, err := e.app.UpdatePeer(ctx, model.NewDomainPeer(&p))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
@ -277,7 +285,8 @@ func (e peerEndpoint) handleDelete() gin.HandlerFunc {
err := e.app.DeletePeer(ctx, domain.PeerIdentifier(id))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
@ -333,9 +342,10 @@ func (e peerEndpoint) handleConfigGet() gin.HandlerFunc {
// @ID peers_handleQrCodeGet
// @Tags Peer
// @Summary Get peer configuration as qr code.
// @Produce png
// @Produce json
// @Param id path string true "The peer identifier"
// @Success 200 {object} string
// @Success 200 {file} binary
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /peer/config-qr/{id} [get]
@ -403,7 +413,8 @@ func (e peerEndpoint) handleEmailPost() gin.HandlerFunc {
}
err = e.app.SendPeerEmail(ctx, req.LinkOnly, peerIds...)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
@ -434,7 +445,8 @@ func (e peerEndpoint) handleStatsGet() gin.HandlerFunc {
stats, err := e.app.GetPeerStats(ctx, domain.InterfaceIdentifier(interfaceId))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}

View File

@ -1,11 +1,12 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/api/v0/model"
"github.com/h44z/wg-portal/internal/domain"
"net/http"
)
type userEndpoint struct {
@ -27,6 +28,8 @@ func (e userEndpoint) RegisterRoutes(g *gin.RouterGroup, authenticator *authenti
apiGroup.POST("/new", e.authenticator.LoggedIn(ScopeAdmin), e.handleCreatePost())
apiGroup.GET("/:id/peers", e.authenticator.UserIdMatch("id"), e.handlePeersGet())
apiGroup.GET("/:id/stats", e.authenticator.UserIdMatch("id"), e.handleStatsGet())
apiGroup.POST("/:id/api/enable", e.authenticator.UserIdMatch("id"), e.handleApiEnablePost())
apiGroup.POST("/:id/api/disable", e.authenticator.UserIdMatch("id"), e.handleApiDisablePost())
}
// handleAllGet returns a gorm handler function.
@ -44,7 +47,8 @@ func (e userEndpoint) handleAllGet() gin.HandlerFunc {
users, err := e.app.GetAllUsers(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
@ -74,11 +78,12 @@ func (e userEndpoint) handleSingleGet() gin.HandlerFunc {
user, err := e.app.GetUser(ctx, domain.UserIdentifier(id))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
c.JSON(http.StatusOK, model.NewUser(user))
c.JSON(http.StatusOK, model.NewUser(user, true))
}
}
@ -118,11 +123,12 @@ func (e userEndpoint) handleUpdatePut() gin.HandlerFunc {
updateUser, err := e.app.UpdateUser(ctx, model.NewDomainUser(&user))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
c.JSON(http.StatusOK, model.NewUser(updateUser))
c.JSON(http.StatusOK, model.NewUser(updateUser, false))
}
}
@ -150,11 +156,12 @@ func (e userEndpoint) handleCreatePost() gin.HandlerFunc {
newUser, err := e.app.CreateUser(ctx, model.NewDomainUser(&user))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
c.JSON(http.StatusOK, model.NewUser(newUser))
c.JSON(http.StatusOK, model.NewUser(newUser, false))
}
}
@ -174,13 +181,15 @@ func (e userEndpoint) handlePeersGet() gin.HandlerFunc {
interfaceId := Base64UrlDecode(c.Param("id"))
if interfaceId == "" {
c.JSON(http.StatusBadRequest, model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
c.JSON(http.StatusBadRequest,
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
return
}
peers, err := e.app.GetUserPeers(ctx, domain.UserIdentifier(interfaceId))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
@ -204,13 +213,15 @@ func (e userEndpoint) handleStatsGet() gin.HandlerFunc {
userId := Base64UrlDecode(c.Param("id"))
if userId == "" {
c.JSON(http.StatusBadRequest, model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
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()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
@ -241,10 +252,75 @@ func (e userEndpoint) handleDelete() gin.HandlerFunc {
err := e.app.DeleteUser(ctx, domain.UserIdentifier(id))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
c.JSON(http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// handleApiEnablePost returns a gorm handler function.
//
// @ID users_handleApiEnablePost
// @Tags Users
// @Summary Enable the REST API for the given user.
// @Produce json
// @Success 200 {object} model.User
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/{id}/api/enable [post]
func (e userEndpoint) handleApiEnablePost() 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
}
user, err := e.app.ActivateApi(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.NewUser(user, true))
}
}
// handleApiDisablePost returns a gorm handler function.
//
// @ID users_handleApiDisablePost
// @Tags Users
// @Summary Disable the REST API for the given user.
// @Produce json
// @Success 200 {object} model.User
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/{id}/api/disable [post]
func (e userEndpoint) handleApiDisablePost() 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
}
user, err := e.app.DeactivateApi(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.NewUser(user, false))
}
}

View File

@ -9,4 +9,5 @@ type Settings struct {
MailLinkOnly bool `json:"MailLinkOnly"`
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
SelfProvisioning bool `json:"SelfProvisioning"`
ApiAdminOnly bool `json:"ApiAdminOnly"`
}

View File

@ -25,37 +25,50 @@ type User struct {
Locked bool `json:"Locked"` // if this field is set, the user is locked
LockedReason string `json:"LockedReason"` // the reason why the user has been locked
ApiToken string `json:"ApiToken"`
ApiTokenCreated *time.Time `json:"ApiTokenCreated,omitempty"`
ApiEnabled bool `json:"ApiEnabled"`
// Calculated
PeerCount int `json:"PeerCount"`
}
func NewUser(src *domain.User) *User {
return &User{
Identifier: string(src.Identifier),
Email: src.Email,
Source: string(src.Source),
ProviderName: src.ProviderName,
IsAdmin: src.IsAdmin,
Firstname: src.Firstname,
Lastname: src.Lastname,
Phone: src.Phone,
Department: src.Department,
Notes: src.Notes,
Password: "", // never fill password
Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason,
Locked: src.IsLocked(),
LockedReason: src.LockedReason,
func NewUser(src *domain.User, exposeCreds bool) *User {
u := &User{
Identifier: string(src.Identifier),
Email: src.Email,
Source: string(src.Source),
ProviderName: src.ProviderName,
IsAdmin: src.IsAdmin,
Firstname: src.Firstname,
Lastname: src.Lastname,
Phone: src.Phone,
Department: src.Department,
Notes: src.Notes,
Password: "", // never fill password
Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason,
Locked: src.IsLocked(),
LockedReason: src.LockedReason,
ApiToken: "", // by default, do not expose API token
ApiTokenCreated: src.ApiTokenCreated,
ApiEnabled: src.IsApiEnabled(),
PeerCount: src.LinkedPeerCount,
}
if exposeCreds {
u.ApiToken = src.ApiToken
}
return u
}
func NewUsers(src []domain.User) []User {
results := make([]User, len(src))
for i := range src {
results[i] = *NewUser(&src[i])
results[i] = *NewUser(&src[i], false)
}
return results

View File

@ -0,0 +1,109 @@
package backend
import (
"context"
"fmt"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
type InterfaceServiceInterfaceManagerRepo interface {
GetAllInterfacesAndPeers(ctx context.Context) ([]domain.Interface, [][]domain.Peer, error)
GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error)
CreateInterface(ctx context.Context, in *domain.Interface) (*domain.Interface, error)
UpdateInterface(ctx context.Context, in *domain.Interface) (*domain.Interface, []domain.Peer, error)
DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error
}
type InterfaceService struct {
cfg *config.Config
interfaces InterfaceServiceInterfaceManagerRepo
users PeerServiceUserManagerRepo
}
func NewInterfaceService(cfg *config.Config, interfaces InterfaceServiceInterfaceManagerRepo) *InterfaceService {
return &InterfaceService{
cfg: cfg,
interfaces: interfaces,
}
}
func (s InterfaceService) GetAll(ctx context.Context) ([]domain.Interface, [][]domain.Peer, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, nil, err
}
interfaces, interfacePeers, err := s.interfaces.GetAllInterfacesAndPeers(ctx)
if err != nil {
return nil, nil, err
}
return interfaces, interfacePeers, nil
}
func (s InterfaceService) GetById(ctx context.Context, id domain.InterfaceIdentifier) (
*domain.Interface,
[]domain.Peer,
error,
) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, nil, err
}
interfaceData, interfacePeers, err := s.interfaces.GetInterfaceAndPeers(ctx, id)
if err != nil {
return nil, nil, err
}
return interfaceData, interfacePeers, nil
}
func (s InterfaceService) Create(ctx context.Context, iface *domain.Interface) (*domain.Interface, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
}
createdInterface, err := s.interfaces.CreateInterface(ctx, iface)
if err != nil {
return nil, err
}
return createdInterface, nil
}
func (s InterfaceService) Update(ctx context.Context, id domain.InterfaceIdentifier, iface *domain.Interface) (
*domain.Interface,
[]domain.Peer,
error,
) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, nil, err
}
if iface.Identifier != id {
return nil, nil, fmt.Errorf("interface id mismatch: %s != %s: %w",
iface.Identifier, id, domain.ErrInvalidData)
}
updatedInterface, updatedPeers, err := s.interfaces.UpdateInterface(ctx, iface)
if err != nil {
return nil, nil, err
}
return updatedInterface, updatedPeers, nil
}
func (s InterfaceService) Delete(ctx context.Context, id domain.InterfaceIdentifier) error {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return err
}
err := s.interfaces.DeleteInterface(ctx, id)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,143 @@
package backend
import (
"context"
"errors"
"fmt"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
type PeerServicePeerManagerRepo interface {
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error)
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error)
CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Peer, error)
UpdatePeer(ctx context.Context, peer *domain.Peer) (*domain.Peer, error)
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
}
type PeerServiceUserManagerRepo interface {
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
}
type PeerService struct {
cfg *config.Config
peers PeerServicePeerManagerRepo
users PeerServiceUserManagerRepo
}
func NewPeerService(
cfg *config.Config,
peers PeerServicePeerManagerRepo,
users PeerServiceUserManagerRepo,
) *PeerService {
return &PeerService{
cfg: cfg,
peers: peers,
users: users,
}
}
func (s PeerService) GetForInterface(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.Peer, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
}
_, interfacePeers, err := s.peers.GetInterfaceAndPeers(ctx, id)
if err != nil {
return nil, err
}
return interfacePeers, nil
}
func (s PeerService) GetForUser(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) {
if err := domain.ValidateUserAccessRights(ctx, id); err != nil {
return nil, err
}
if s.cfg.Advanced.ApiAdminOnly && !domain.GetUserInfo(ctx).IsAdmin {
return nil, errors.Join(errors.New("only admins can access this endpoint"), domain.ErrNoPermission)
}
user, err := s.users.GetUser(ctx, id)
if err != nil {
return nil, err
}
userPeers, err := s.peers.GetUserPeers(ctx, user.Identifier)
if err != nil {
return nil, err
}
return userPeers, nil
}
func (s PeerService) GetById(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) {
if s.cfg.Advanced.ApiAdminOnly && !domain.GetUserInfo(ctx).IsAdmin {
return nil, errors.Join(errors.New("only admins can access this endpoint"), domain.ErrNoPermission)
}
peer, err := s.peers.GetPeer(ctx, id)
if err != nil {
return nil, err
}
// Check if the user has access rights to the requested peer.
// If the peer is not linked to any user, access is granted only for admins.
if err := domain.ValidateUserAccessRights(ctx, peer.UserIdentifier); err != nil {
return nil, err
}
return peer, nil
}
func (s PeerService) Create(ctx context.Context, peer *domain.Peer) (*domain.Peer, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
}
if peer.Identifier != domain.PeerIdentifier(peer.Interface.PublicKey) {
return nil, fmt.Errorf("peer id mismatch: %s != %s: %w",
peer.Identifier, peer.Interface.PublicKey, domain.ErrInvalidData)
}
createdPeer, err := s.peers.CreatePeer(ctx, peer)
if err != nil {
return nil, err
}
return createdPeer, nil
}
func (s PeerService) Update(ctx context.Context, _ domain.PeerIdentifier, peer *domain.Peer) (
*domain.Peer,
error,
) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
}
updatedPeer, err := s.peers.UpdatePeer(ctx, peer)
if err != nil {
return nil, err
}
return updatedPeer, nil
}
func (s PeerService) Delete(ctx context.Context, id domain.PeerIdentifier) error {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return err
}
err := s.peers.DeletePeer(ctx, id)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,174 @@
package backend
import (
"context"
"fmt"
"io"
"github.com/h44z/wg-portal/internal/app/api/v1/models"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
type ProvisioningServiceUserManagerRepo interface {
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
}
type ProvisioningServicePeerManagerRepo interface {
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error)
GetUserPeers(context.Context, domain.UserIdentifier) ([]domain.Peer, error)
PreparePeer(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Peer, error)
CreatePeer(ctx context.Context, p *domain.Peer) (*domain.Peer, error)
}
type ProvisioningServiceConfigFileManagerRepo interface {
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
}
type ProvisioningService struct {
cfg *config.Config
users ProvisioningServiceUserManagerRepo
peers ProvisioningServicePeerManagerRepo
configFiles ProvisioningServiceConfigFileManagerRepo
}
func NewProvisioningService(
cfg *config.Config,
users ProvisioningServiceUserManagerRepo,
peers ProvisioningServicePeerManagerRepo,
configFiles ProvisioningServiceConfigFileManagerRepo,
) *ProvisioningService {
return &ProvisioningService{
cfg: cfg,
users: users,
peers: peers,
configFiles: configFiles,
}
}
func (p ProvisioningService) GetUserAndPeers(
ctx context.Context,
userId domain.UserIdentifier,
email string,
) (*domain.User, []domain.Peer, error) {
// first fetch user
var user *domain.User
switch {
case userId != "":
u, err := p.users.GetUser(ctx, userId)
if err != nil {
return nil, nil, err
}
user = u
case email != "":
u, err := p.users.GetUserByEmail(ctx, email)
if err != nil {
return nil, nil, err
}
user = u
default:
return nil, nil, fmt.Errorf("either UserId or Email must be set: %w", domain.ErrInvalidData)
}
if err := domain.ValidateUserAccessRights(ctx, user.Identifier); err != nil {
return nil, nil, err
}
peers, err := p.peers.GetUserPeers(ctx, user.Identifier)
if err != nil {
return nil, nil, err
}
return user, peers, nil
}
func (p ProvisioningService) GetPeerConfig(ctx context.Context, peerId domain.PeerIdentifier) ([]byte, error) {
peer, err := p.peers.GetPeer(ctx, peerId)
if err != nil {
return nil, err
}
if err := domain.ValidateUserAccessRights(ctx, peer.UserIdentifier); err != nil {
return nil, err
}
peerCfgReader, err := p.configFiles.GetPeerConfig(ctx, peer.Identifier)
if err != nil {
return nil, err
}
peerCfgData, err := io.ReadAll(peerCfgReader)
if err != nil {
return nil, err
}
return peerCfgData, nil
}
func (p ProvisioningService) GetPeerQrPng(ctx context.Context, peerId domain.PeerIdentifier) ([]byte, error) {
peer, err := p.peers.GetPeer(ctx, peerId)
if err != nil {
return nil, err
}
if err := domain.ValidateUserAccessRights(ctx, peer.UserIdentifier); err != nil {
return nil, err
}
peerCfgQrReader, err := p.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier)
if err != nil {
return nil, err
}
peerCfgQrData, err := io.ReadAll(peerCfgQrReader)
if err != nil {
return nil, err
}
return peerCfgQrData, nil
}
func (p ProvisioningService) NewPeer(ctx context.Context, req models.ProvisioningRequest) (*domain.Peer, error) {
if req.UserIdentifier == "" {
req.UserIdentifier = string(domain.GetUserInfo(ctx).Id) // use authenticated user id if not set
}
// check permissions
if err := domain.ValidateUserAccessRights(ctx, domain.UserIdentifier(req.UserIdentifier)); err != nil {
return nil, err
}
if !p.cfg.Core.SelfProvisioningAllowed {
// only admins can create new peers if self-provisioning is disabled
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
}
}
// prepare new peer
peer, err := p.peers.PreparePeer(ctx, domain.InterfaceIdentifier(req.InterfaceIdentifier))
if err != nil {
return nil, fmt.Errorf("failed to prepare new peer: %w", err)
}
peer.UserIdentifier = domain.UserIdentifier(req.UserIdentifier) // overwrite context user id with the one from the request
if req.PublicKey != "" {
peer.Identifier = domain.PeerIdentifier(req.PublicKey)
peer.Interface.PublicKey = req.PublicKey
peer.Interface.PrivateKey = "" // clear private key if public key is set, WireGuard Portal does not know the private key in that case
}
if req.PresharedKey != "" {
peer.PresharedKey = domain.PreSharedKey(req.PresharedKey)
}
peer.GenerateDisplayName("API")
// save new peer
peer, err = p.peers.CreatePeer(ctx, peer)
if err != nil {
return nil, fmt.Errorf("failed to create new peer: %w", err)
}
return peer, nil
}

View File

@ -0,0 +1,107 @@
package backend
import (
"context"
"errors"
"fmt"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
type UserManagerRepo interface {
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
GetAllUsers(ctx context.Context) ([]domain.User, error)
CreateUser(ctx context.Context, user *domain.User) (*domain.User, error)
UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error)
DeleteUser(ctx context.Context, id domain.UserIdentifier) error
}
type UserService struct {
cfg *config.Config
users UserManagerRepo
}
func NewUserService(cfg *config.Config, users UserManagerRepo) *UserService {
return &UserService{
cfg: cfg,
users: users,
}
}
func (s UserService) GetAll(ctx context.Context) ([]domain.User, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
}
allUsers, err := s.users.GetAllUsers(ctx)
if err != nil {
return nil, err
}
return allUsers, nil
}
func (s UserService) GetById(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
if err := domain.ValidateUserAccessRights(ctx, id); err != nil {
return nil, err
}
if s.cfg.Advanced.ApiAdminOnly && !domain.GetUserInfo(ctx).IsAdmin {
return nil, errors.Join(errors.New("only admins can access this endpoint"), domain.ErrNoPermission)
}
user, err := s.users.GetUser(ctx, id)
if err != nil {
return nil, err
}
return user, nil
}
func (s UserService) Create(ctx context.Context, user *domain.User) (*domain.User, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
}
createdUser, err := s.users.CreateUser(ctx, user)
if err != nil {
return nil, err
}
return createdUser, nil
}
func (s UserService) Update(ctx context.Context, id domain.UserIdentifier, user *domain.User) (
*domain.User,
error,
) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
}
if id != user.Identifier {
return nil, fmt.Errorf("user id mismatch: %s != %s: %w", id, user.Identifier, domain.ErrInvalidData)
}
updatedUser, err := s.users.UpdateUser(ctx, user)
if err != nil {
return nil, err
}
return updatedUser, nil
}
func (s UserService) Delete(ctx context.Context, id domain.UserIdentifier) error {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return err
}
err := s.users.DeleteUser(ctx, id)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,81 @@
package handlers
import (
"errors"
"net/http"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/app/api/core"
"github.com/h44z/wg-portal/internal/app/api/v1/models"
"github.com/h44z/wg-portal/internal/domain"
)
type Handler interface {
GetName() string
RegisterRoutes(g *gin.RouterGroup, authenticator *authenticationHandler)
}
// To compile the API documentation use the
// api_build_tool
// command that can be found in the $PROJECT_ROOT/cmd/api_build_tool directory.
// @title WireGuard Portal Public API
// @version 1.0
// @description The WireGuard Portal REST API enables efficient management of WireGuard VPN configurations through a set of JSON-based endpoints.
// @description It supports creating and editing peers, interfaces, and user profiles, while also providing role-based access control and auditing.
// @description This API allows seamless integration with external tools or scripts for automated network configuration and administration.
// @license.name MIT
// @license.url https://github.com/h44z/wg-portal/blob/master/LICENSE.txt
// @contact.name WireGuard Portal Project
// @contact.url https://github.com/h44z/wg-portal
// @securityDefinitions.basic BasicAuth
// @BasePath /api/v1
// @query.collection.format multi
func NewRestApi(userSource UserSource, handlers ...Handler) core.ApiEndpointSetupFunc {
authenticator := &authenticationHandler{
userSource: userSource,
}
return func() (core.ApiVersion, core.GroupSetupFn) {
return "v1", func(group *gin.RouterGroup) {
group.Use(cors.Default())
// Handler functions
for _, h := range handlers {
h.RegisterRoutes(group, authenticator)
}
}
}
}
func ParseServiceError(err error) (int, models.Error) {
if err == nil {
return 500, models.Error{
Code: 500,
Message: "unknown server error",
}
}
code := http.StatusInternalServerError
switch {
case errors.Is(err, domain.ErrNotFound):
code = http.StatusNotFound
case errors.Is(err, domain.ErrNoPermission):
code = http.StatusForbidden
case errors.Is(err, domain.ErrDuplicateEntry):
code = http.StatusConflict
case errors.Is(err, domain.ErrInvalidData):
code = http.StatusBadRequest
}
return code, models.Error{
Code: code,
Message: err.Error(),
}
}

View File

@ -0,0 +1,220 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/app/api/v1/models"
"github.com/h44z/wg-portal/internal/domain"
)
type InterfaceEndpointInterfaceService interface {
GetAll(context.Context) ([]domain.Interface, [][]domain.Peer, error)
GetById(context.Context, domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error)
Create(context.Context, *domain.Interface) (*domain.Interface, error)
Update(context.Context, domain.InterfaceIdentifier, *domain.Interface) (*domain.Interface, []domain.Peer, error)
Delete(context.Context, domain.InterfaceIdentifier) error
}
type InterfaceEndpoint struct {
interfaces InterfaceEndpointInterfaceService
}
func NewInterfaceEndpoint(interfaceService InterfaceEndpointInterfaceService) *InterfaceEndpoint {
return &InterfaceEndpoint{
interfaces: interfaceService,
}
}
func (e InterfaceEndpoint) GetName() string {
return "InterfaceEndpoint"
}
func (e InterfaceEndpoint) RegisterRoutes(g *gin.RouterGroup, authenticator *authenticationHandler) {
apiGroup := g.Group("/interface", authenticator.LoggedIn())
apiGroup.GET("/all", authenticator.LoggedIn(ScopeAdmin), e.handleAllGet())
apiGroup.GET("/by-id/:id", authenticator.LoggedIn(ScopeAdmin), e.handleByIdGet())
apiGroup.POST("/new", authenticator.LoggedIn(ScopeAdmin), e.handleCreatePost())
apiGroup.PUT("/by-id/:id", authenticator.LoggedIn(ScopeAdmin), e.handleUpdatePut())
apiGroup.DELETE("/by-id/:id", authenticator.LoggedIn(ScopeAdmin), e.handleDelete())
}
// handleAllGet returns a gorm Handler function.
//
// @ID interface_handleAllGet
// @Tags Interfaces
// @Summary Get all interface records.
// @Produce json
// @Success 200 {object} []models.Interface
// @Failure 401 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /interface/all [get]
// @Security BasicAuth
func (e InterfaceEndpoint) handleAllGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
allInterfaces, allPeersPerInterface, err := e.interfaces.GetAll(ctx)
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewInterfaces(allInterfaces, allPeersPerInterface))
}
}
// handleByIdGet returns a gorm Handler function.
//
// @ID interfaces_handleByIdGet
// @Tags Interfaces
// @Summary Get a specific interface record by its identifier.
// @Param id path string true "The interface identifier."
// @Produce json
// @Success 200 {object} models.Interface
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /interface/by-id/{id} [get]
// @Security BasicAuth
func (e InterfaceEndpoint) handleByIdGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
return
}
iface, interfacePeers, err := e.interfaces.GetById(ctx, domain.InterfaceIdentifier(id))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewInterface(iface, interfacePeers))
}
}
// handleCreatePost returns a gorm handler function.
//
// @ID interfaces_handleCreatePost
// @Tags Interfaces
// @Summary Create a new interface record.
// @Param request body models.Interface true "The interface data."
// @Produce json
// @Success 200 {object} models.Interface
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 409 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /interface/new [post]
// @Security BasicAuth
func (e InterfaceEndpoint) handleCreatePost() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
var iface models.Interface
err := c.BindJSON(&iface)
if err != nil {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
newInterface, err := e.interfaces.Create(ctx, models.NewDomainInterface(&iface))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewInterface(newInterface, nil))
}
}
// handleUpdatePut returns a gorm handler function.
//
// @ID interfaces_handleUpdatePut
// @Tags Interfaces
// @Summary Update an interface record.
// @Param id path string true "The interface identifier."
// @Param request body models.Interface true "The interface data."
// @Produce json
// @Success 200 {object} models.Interface
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /interface/by-id/{id} [put]
// @Security BasicAuth
func (e InterfaceEndpoint) handleUpdatePut() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
return
}
var iface models.Interface
err := c.BindJSON(&iface)
if err != nil {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
updatedInterface, updatedInterfacePeers, err := e.interfaces.Update(
ctx,
domain.InterfaceIdentifier(id),
models.NewDomainInterface(&iface),
)
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewInterface(updatedInterface, updatedInterfacePeers))
}
}
// handleDelete returns a gorm handler function.
//
// @ID interfaces_handleDelete
// @Tags Interfaces
// @Summary Delete the interface record.
// @Param id path string true "The interface identifier."
// @Produce json
// @Success 204 "No content if deletion was successful."
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /interface/by-id/{id} [delete]
// @Security BasicAuth
func (e InterfaceEndpoint) handleDelete() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
return
}
err := e.interfaces.Delete(ctx, domain.InterfaceIdentifier(id))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.Status(http.StatusNoContent)
}
}

View File

@ -0,0 +1,261 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/app/api/v1/models"
"github.com/h44z/wg-portal/internal/domain"
)
type PeerService interface {
GetForInterface(context.Context, domain.InterfaceIdentifier) ([]domain.Peer, error)
GetForUser(context.Context, domain.UserIdentifier) ([]domain.Peer, error)
GetById(context.Context, domain.PeerIdentifier) (*domain.Peer, error)
Create(context.Context, *domain.Peer) (*domain.Peer, error)
Update(context.Context, domain.PeerIdentifier, *domain.Peer) (*domain.Peer, error)
Delete(context.Context, domain.PeerIdentifier) error
}
type PeerEndpoint struct {
peers PeerService
}
func NewPeerEndpoint(peerService PeerService) *PeerEndpoint {
return &PeerEndpoint{
peers: peerService,
}
}
func (e PeerEndpoint) GetName() string {
return "PeerEndpoint"
}
func (e PeerEndpoint) RegisterRoutes(g *gin.RouterGroup, authenticator *authenticationHandler) {
apiGroup := g.Group("/peer", authenticator.LoggedIn())
apiGroup.GET("/by-interface/:id", authenticator.LoggedIn(ScopeAdmin), e.handleAllForInterfaceGet())
apiGroup.GET("/by-user/:id", authenticator.LoggedIn(), e.handleAllForUserGet())
apiGroup.GET("/by-id/:id", authenticator.LoggedIn(), e.handleByIdGet())
apiGroup.POST("/new", authenticator.LoggedIn(ScopeAdmin), e.handleCreatePost())
apiGroup.PUT("/by-id/:id", authenticator.LoggedIn(ScopeAdmin), e.handleUpdatePut())
apiGroup.DELETE("/by-id/:id", authenticator.LoggedIn(ScopeAdmin), e.handleDelete())
}
// handleAllForInterfaceGet returns a gorm Handler function.
//
// @ID peers_handleAllForInterfaceGet
// @Tags Peers
// @Summary Get all peer records for a given WireGuard interface.
// @Param id path string true "The WireGuard interface identifier."
// @Produce json
// @Success 200 {object} []models.Peer
// @Failure 401 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /peer/by-interface/{id} [get]
// @Security BasicAuth
func (e PeerEndpoint) handleAllForInterfaceGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
return
}
interfacePeers, err := e.peers.GetForInterface(ctx, domain.InterfaceIdentifier(id))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewPeers(interfacePeers))
}
}
// handleAllForUserGet returns a gorm Handler function.
//
// @ID peers_handleAllForUserGet
// @Tags Peers
// @Summary Get all peer records for a given user.
// @Description Normal users can only access their own records. Admins can access all records.
// @Param id path string true "The user identifier."
// @Produce json
// @Success 200 {object} []models.Peer
// @Failure 401 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /peer/by-user/{id} [get]
// @Security BasicAuth
func (e PeerEndpoint) handleAllForUserGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing user id"})
return
}
interfacePeers, err := e.peers.GetForUser(ctx, domain.UserIdentifier(id))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewPeers(interfacePeers))
}
}
// handleByIdGet returns a gorm Handler function.
//
// @ID peers_handleByIdGet
// @Tags Peers
// @Summary Get a specific peer record by its identifier (public key).
// @Description Normal users can only access their own records. Admins can access all records.
// @Param id path string true "The peer identifier (public key)."
// @Produce json
// @Success 200 {object} models.Peer
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /peer/by-id/{id} [get]
// @Security BasicAuth
func (e PeerEndpoint) handleByIdGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing peer id"})
return
}
peer, err := e.peers.GetById(ctx, domain.PeerIdentifier(id))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewPeer(peer))
}
}
// handleCreatePost returns a gorm handler function.
//
// @ID peers_handleCreatePost
// @Tags Peers
// @Summary Create a new peer record.
// @Description Only admins can create new records.
// @Param request body models.Peer true "The peer data."
// @Produce json
// @Success 200 {object} models.Peer
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 409 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /peer/new [post]
// @Security BasicAuth
func (e PeerEndpoint) handleCreatePost() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
var peer models.Peer
err := c.BindJSON(&peer)
if err != nil {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
newPeer, err := e.peers.Create(ctx, models.NewDomainPeer(&peer))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewPeer(newPeer))
}
}
// handleUpdatePut returns a gorm handler function.
//
// @ID peers_handleUpdatePut
// @Tags Peers
// @Summary Update a peer record.
// @Description Only admins can update existing records.
// @Param id path string true "The peer identifier."
// @Param request body models.Peer true "The peer data."
// @Produce json
// @Success 200 {object} models.Peer
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /peer/by-id/{id} [put]
// @Security BasicAuth
func (e PeerEndpoint) handleUpdatePut() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing peer id"})
return
}
var peer models.Peer
err := c.BindJSON(&peer)
if err != nil {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
updatedPeer, err := e.peers.Update(ctx, domain.PeerIdentifier(id), models.NewDomainPeer(&peer))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewPeer(updatedPeer))
}
}
// handleDelete returns a gorm handler function.
//
// @ID peers_handleDelete
// @Tags Peers
// @Summary Delete the peer record.
// @Param id path string true "The peer identifier."
// @Produce json
// @Success 204 "No content if deletion was successful."
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /peer/by-id/{id} [delete]
// @Security BasicAuth
func (e PeerEndpoint) handleDelete() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing peer id"})
return
}
err := e.peers.Delete(ctx, domain.PeerIdentifier(id))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.Status(http.StatusNoContent)
}
}

View File

@ -0,0 +1,195 @@
package handlers
import (
"context"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/app/api/v1/models"
"github.com/h44z/wg-portal/internal/domain"
)
type ProvisioningEndpointProvisioningService interface {
GetUserAndPeers(ctx context.Context, userId domain.UserIdentifier, email string) (
*domain.User,
[]domain.Peer,
error,
)
GetPeerConfig(ctx context.Context, peerId domain.PeerIdentifier) ([]byte, error)
GetPeerQrPng(ctx context.Context, peerId domain.PeerIdentifier) ([]byte, error)
NewPeer(ctx context.Context, req models.ProvisioningRequest) (*domain.Peer, error)
}
type ProvisioningEndpoint struct {
provisioning ProvisioningEndpointProvisioningService
}
func NewProvisioningEndpoint(provisioning ProvisioningEndpointProvisioningService) *ProvisioningEndpoint {
return &ProvisioningEndpoint{
provisioning: provisioning,
}
}
func (e ProvisioningEndpoint) GetName() string {
return "ProvisioningEndpoint"
}
func (e ProvisioningEndpoint) RegisterRoutes(g *gin.RouterGroup, authenticator *authenticationHandler) {
apiGroup := g.Group("/provisioning", authenticator.LoggedIn())
apiGroup.GET("/data/user-info", authenticator.LoggedIn(), e.handleUserInfoGet())
apiGroup.GET("/data/peer-config", authenticator.LoggedIn(), e.handlePeerConfigGet())
apiGroup.GET("/data/peer-qr", authenticator.LoggedIn(), e.handlePeerQrGet())
apiGroup.POST("/new-peer", authenticator.LoggedIn(), e.handleNewPeerPost())
}
// handleUserInfoGet returns a gorm Handler function.
//
// @ID provisioning_handleUserInfoGet
// @Tags Provisioning
// @Summary Get information about all peer records for a given user.
// @Description Normal users can only access their own record. Admins can access all records.
// @Param UserId query string false "The user identifier that should be queried. If not set, the authenticated user is used."
// @Param Email query string false "The email address that should be queried. If UserId is set, this is ignored."
// @Produce json
// @Success 200 {object} models.UserInformation
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /provisioning/data/user-info [get]
// @Security BasicAuth
func (e ProvisioningEndpoint) handleUserInfoGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := strings.TrimSpace(c.Query("UserId"))
email := strings.TrimSpace(c.Query("Email"))
if id == "" && email == "" {
id = string(domain.GetUserInfo(ctx).Id)
}
user, peers, err := e.provisioning.GetUserAndPeers(ctx, domain.UserIdentifier(id), email)
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewUserInformation(user, peers))
}
}
// handlePeerConfigGet returns a gorm Handler function.
//
// @ID provisioning_handlePeerConfigGet
// @Tags Provisioning
// @Summary Get the peer configuration in wg-quick format.
// @Description Normal users can only access their own record. Admins can access all records.
// @Param PeerId query string true "The peer identifier (public key) that should be queried."
// @Produce plain
// @Produce json
// @Success 200 {string} string "The WireGuard configuration file"
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /provisioning/data/peer-config [get]
// @Security BasicAuth
func (e ProvisioningEndpoint) handlePeerConfigGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := strings.TrimSpace(c.Query("PeerId"))
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing peer id"})
return
}
peerConfig, err := e.provisioning.GetPeerConfig(ctx, domain.PeerIdentifier(id))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.Data(http.StatusOK, "text/plain", peerConfig)
}
}
// handlePeerQrGet returns a gorm Handler function.
//
// @ID provisioning_handlePeerQrGet
// @Tags Provisioning
// @Summary Get the peer configuration as QR code.
// @Description Normal users can only access their own record. Admins can access all records.
// @Param PeerId query string true "The peer identifier (public key) that should be queried."
// @Produce png
// @Produce json
// @Success 200 {file} binary "The WireGuard configuration QR code"
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /provisioning/data/peer-qr [get]
// @Security BasicAuth
func (e ProvisioningEndpoint) handlePeerQrGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := strings.TrimSpace(c.Query("PeerId"))
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing peer id"})
return
}
peerConfigQrCode, err := e.provisioning.GetPeerQrPng(ctx, domain.PeerIdentifier(id))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.Data(http.StatusOK, "image/png", peerConfigQrCode)
}
}
// handleNewPeerPost returns a gorm Handler function.
//
// @ID provisioning_handleNewPeerPost
// @Tags Provisioning
// @Summary Create a new peer for the given interface and user.
// @Description Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.
// @Param request body models.ProvisioningRequest true "Provisioning request model."
// @Produce json
// @Success 200 {object} models.Peer
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /provisioning/new-peer [post]
// @Security BasicAuth
func (e ProvisioningEndpoint) handleNewPeerPost() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
var req models.ProvisioningRequest
err := c.BindJSON(&req)
if err != nil {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
peer, err := e.provisioning.NewPeer(ctx, req)
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewPeer(peer))
}
}

View File

@ -0,0 +1,218 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/app/api/v1/models"
"github.com/h44z/wg-portal/internal/domain"
)
type UserService interface {
GetAll(ctx context.Context) ([]domain.User, error)
GetById(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
Create(ctx context.Context, user *domain.User) (*domain.User, error)
Update(ctx context.Context, id domain.UserIdentifier, user *domain.User) (*domain.User, error)
Delete(ctx context.Context, id domain.UserIdentifier) error
}
type UserEndpoint struct {
users UserService
}
func NewUserEndpoint(userService UserService) *UserEndpoint {
return &UserEndpoint{
users: userService,
}
}
func (e UserEndpoint) GetName() string {
return "UserEndpoint"
}
func (e UserEndpoint) RegisterRoutes(g *gin.RouterGroup, authenticator *authenticationHandler) {
apiGroup := g.Group("/user", authenticator.LoggedIn())
apiGroup.GET("/all", authenticator.LoggedIn(ScopeAdmin), e.handleAllGet())
apiGroup.GET("/by-id/:id", authenticator.LoggedIn(), e.handleByIdGet())
apiGroup.POST("/new", authenticator.LoggedIn(ScopeAdmin), e.handleCreatePost())
apiGroup.PUT("/by-id/:id", authenticator.LoggedIn(ScopeAdmin), e.handleUpdatePut())
apiGroup.DELETE("/by-id/:id", authenticator.LoggedIn(ScopeAdmin), e.handleDelete())
}
// handleAllGet returns a gorm Handler function.
//
// @ID users_handleAllGet
// @Tags Users
// @Summary Get all user records.
// @Produce json
// @Success 200 {object} []models.User
// @Failure 401 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /user/all [get]
// @Security BasicAuth
func (e UserEndpoint) handleAllGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
users, err := e.users.GetAll(ctx)
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewUsers(users))
}
}
// handleByIdGet returns a gorm Handler function.
//
// @ID users_handleByIdGet
// @Tags Users
// @Summary Get a specific user record by its internal identifier.
// @Description Normal users can only access their own record. Admins can access all records.
// @Param id path string true "The user identifier."
// @Produce json
// @Success 200 {object} models.User
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /user/by-id/{id} [get]
// @Security BasicAuth
func (e UserEndpoint) handleByIdGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing user id"})
return
}
user, err := e.users.GetById(ctx, domain.UserIdentifier(id))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewUser(user, true))
}
}
// handleCreatePost returns a gorm handler function.
//
// @ID users_handleCreatePost
// @Tags Users
// @Summary Create a new user record.
// @Description Only admins can create new records.
// @Param request body models.User true "The user data."
// @Produce json
// @Success 200 {object} models.User
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 409 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /user/new [post]
// @Security BasicAuth
func (e UserEndpoint) handleCreatePost() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
var user models.User
err := c.BindJSON(&user)
if err != nil {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
newUser, err := e.users.Create(ctx, models.NewDomainUser(&user))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewUser(newUser, true))
}
}
// handleUpdatePut returns a gorm handler function.
//
// @ID users_handleUpdatePut
// @Tags Users
// @Summary Update a user record.
// @Description Only admins can update existing records.
// @Param id path string true "The user identifier."
// @Param request body models.User true "The user data."
// @Produce json
// @Success 200 {object} models.User
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /user/by-id/{id} [put]
// @Security BasicAuth
func (e UserEndpoint) handleUpdatePut() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing user id"})
return
}
var user models.User
err := c.BindJSON(&user)
if err != nil {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
updateUser, err := e.users.Update(ctx, domain.UserIdentifier(id), models.NewDomainUser(&user))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewUser(updateUser, true))
}
}
// handleDelete returns a gorm handler function.
//
// @ID users_handleDelete
// @Tags Users
// @Summary Delete the user record.
// @Param id path string true "The user identifier."
// @Produce json
// @Success 204 "No content if deletion was successful."
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /user/by-id/{id} [delete]
// @Security BasicAuth
func (e UserEndpoint) handleDelete() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing user id"})
return
}
err := e.users.Delete(ctx, domain.UserIdentifier(id))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.Status(http.StatusNoContent)
}
}

View File

@ -0,0 +1,92 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/app/api/v0/model"
"github.com/h44z/wg-portal/internal/domain"
)
type Scope string
const (
ScopeAdmin Scope = "ADMIN" // Admin scope contains all other scopes
)
type UserSource interface {
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
}
type authenticationHandler struct {
userSource UserSource
}
// LoggedIn checks if a user is logged in. If scopes are given, they are validated as well.
func (h authenticationHandler) LoggedIn(scopes ...Scope) gin.HandlerFunc {
return func(c *gin.Context) {
username, password, ok := c.Request.BasicAuth()
if !ok || username == "" || password == "" {
// Abort the request with the appropriate error code
c.Abort()
c.JSON(http.StatusUnauthorized, model.Error{Code: http.StatusUnauthorized, Message: "missing credentials"})
return
}
// check if user exists in DB
ctx := domain.SetUserInfo(c.Request.Context(), domain.SystemAdminContextUserInfo())
user, err := h.userSource.GetUser(ctx, domain.UserIdentifier(username))
if err != nil {
// Abort the request with the appropriate error code
c.Abort()
c.JSON(http.StatusUnauthorized, model.Error{Code: http.StatusUnauthorized, Message: "invalid credentials"})
return
}
// validate API token
if err := user.CheckApiToken(password); err != nil {
// Abort the request with the appropriate error code
c.Abort()
c.JSON(http.StatusUnauthorized, model.Error{Code: http.StatusUnauthorized, Message: "invalid credentials"})
return
}
if !UserHasScopes(user, scopes...) {
// Abort the request with the appropriate error code
c.Abort()
c.JSON(http.StatusForbidden, model.Error{Code: http.StatusForbidden, Message: "not enough permissions"})
return
}
c.Set(domain.CtxUserInfo, &domain.ContextUserInfo{
Id: user.Identifier,
IsAdmin: user.IsAdmin,
})
// Continue down the chain to Handler etc
c.Next()
}
}
func UserHasScopes(user *domain.User, scopes ...Scope) bool {
// No scopes give, so the check should succeed
if len(scopes) == 0 {
return true
}
// check if user has admin scope
if user.IsAdmin {
return true
}
// Check if admin scope is required
for _, scope := range scopes {
if scope == ScopeAdmin {
return false
}
}
return true
}

View File

@ -0,0 +1,46 @@
package models
import (
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain"
)
type ConfigOption[T any] struct {
Value T `json:"Value"`
Overridable bool `json:"Overridable,omitempty"`
}
func NewConfigOption[T any](value T, overridable bool) ConfigOption[T] {
return ConfigOption[T]{
Value: value,
Overridable: overridable,
}
}
func ConfigOptionFromDomain[T any](opt domain.ConfigOption[T]) ConfigOption[T] {
return ConfigOption[T]{
Value: opt.Value,
Overridable: opt.Overridable,
}
}
func ConfigOptionToDomain[T any](opt ConfigOption[T]) domain.ConfigOption[T] {
return domain.ConfigOption[T]{
Value: opt.Value,
Overridable: opt.Overridable,
}
}
func StringSliceConfigOptionFromDomain(opt domain.ConfigOption[string]) ConfigOption[[]string] {
return ConfigOption[[]string]{
Value: internal.SliceString(opt.Value),
Overridable: opt.Overridable,
}
}
func StringSliceConfigOptionToDomain(opt ConfigOption[[]string]) domain.ConfigOption[string] {
return domain.ConfigOption[string]{
Value: internal.SliceToString(opt.Value),
Overridable: opt.Overridable,
}
}

View File

@ -0,0 +1,8 @@
package models
// Error represents an error response.
type Error struct {
Code int `json:"Code"` // HTTP status code.
Message string `json:"Message"` // Error message.
Details string `json:"Details,omitempty"` // Additional error details.
}

View File

@ -0,0 +1,201 @@
package models
import (
"time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain"
)
// Interface represents a WireGuard interface.
type Interface struct {
// Identifier is the unique identifier of the interface. It is always equal to the device name of the interface.
Identifier string `json:"Identifier" example:"wg0" binding:"required"`
// DisplayName is a nice display name / description for the interface.
DisplayName string `json:"DisplayName" binding:"omitempty,max=64" example:"My Interface"`
// Mode is the interface type, either 'server', 'client' or 'any'. The mode specifies how WireGuard Portal handles peers for this interface.
Mode string `json:"Mode" example:"server" binding:"required,oneof=server client any"`
// PrivateKey is the private key of the interface.
PrivateKey string `json:"PrivateKey" example:"gI6EdUSYvn8ugXOt8QQD6Yc+JyiZxIhp3GInSWRfWGE=" binding:"required,len=44"`
// PublicKey is the public key of the server interface. The public key is used by peers to connect to the server.
PublicKey string `json:"PublicKey" example:"HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw=" binding:"required,len=44"`
// Disabled is a flag that specifies if the interface is enabled (up) or not (down). Disabled interfaces are not able to accept connections.
Disabled bool `json:"Disabled" example:"false"`
// DisabledReason is the reason why the interface has been disabled.
DisabledReason string `json:"DisabledReason" binding:"required_if=Disabled true" example:"This is a reason why the interface has been disabled."`
// SaveConfig is a flag that specifies if the configuration should be saved to the configuration file (wgX.conf in wg-quick format).
SaveConfig bool `json:"SaveConfig" example:"false"`
// ListenPort is the listening port, for example: 51820. The listening port is only required for server interfaces.
ListenPort int `json:"ListenPort" binding:"omitempty,min=1,max=65535" example:"51820"`
// Addresses is a list of IP addresses (in CIDR format) that are assigned to the interface.
Addresses []string `json:"Addresses" binding:"omitempty,dive,cidr" example:"10.11.12.1/24"`
// Dns is a list of DNS servers that should be set if the interface is up.
Dns []string `json:"Dns" binding:"omitempty,dive,ip" example:"1.1.1.1"`
// DnsSearch is the dns search option string that should be set if the interface is up, will be appended to Dns servers.
DnsSearch []string `json:"DnsSearch" binding:"omitempty,dive,fqdn" example:"wg.local"`
// Mtu is the device MTU of the interface.
Mtu int `json:"Mtu" binding:"omitempty,min=1,max=9000" example:"1420"`
// FirewallMark is an optional firewall mark which is used to handle interface traffic.
FirewallMark uint32 `json:"FirewallMark"`
// RoutingTable is an optional routing table which is used to route interface traffic.
RoutingTable string `json:"RoutingTable"`
// PreUp is an optional action that is executed before the device is up.
PreUp string `json:"PreUp" example:"echo 'Interface is up'"`
// PostUp is an optional action that is executed after the device is up.
PostUp string `json:"PostUp" example:"iptables -A FORWARD -i %i -j ACCEPT"`
// PreDown is an optional action that is executed before the device is down.
PreDown string `json:"PreDown" example:"iptables -D FORWARD -i %i -j ACCEPT"`
// PostDown is an optional action that is executed after the device is down.
PostDown string `json:"PostDown" example:"echo 'Interface is down'"`
// PeerDefNetwork specifies the default subnets from which new peers will get their IP addresses. The subnet is specified in CIDR format.
PeerDefNetwork []string `json:"PeerDefNetwork" example:"10.11.12.0/24"`
// PeerDefDns specifies the default dns servers for a new peer.
PeerDefDns []string `json:"PeerDefDns" example:"8.8.8.8"`
// PeerDefDnsSearch specifies the default dns search options for a new peer.
PeerDefDnsSearch []string `json:"PeerDefDnsSearch" example:"wg.local"`
// PeerDefEndpoint specifies the default endpoint for a new peer.
PeerDefEndpoint string `json:"PeerDefEndpoint" example:"wg.example.com:51820"`
// PeerDefAllowedIPs specifies the default allowed IP addresses for a new peer.
PeerDefAllowedIPs []string `json:"PeerDefAllowedIPs" example:"10.11.12.0/24"`
// PeerDefMtu specifies the default device MTU for a new peer.
PeerDefMtu int `json:"PeerDefMtu" example:"1420"`
// PeerDefPersistentKeepalive specifies the default persistent keep-alive value in seconds for a new peer.
PeerDefPersistentKeepalive int `json:"PeerDefPersistentKeepalive" example:"25"`
// PeerDefFirewallMark specifies the default firewall mark for a new peer.
PeerDefFirewallMark uint32 `json:"PeerDefFirewallMark"`
// PeerDefRoutingTable specifies the default routing table for a new peer.
PeerDefRoutingTable string `json:"PeerDefRoutingTable"`
// PeerDefPreUp specifies the default action that is executed before the device is up for a new peer.
PeerDefPreUp string `json:"PeerDefPreUp"`
// PeerDefPostUp specifies the default action that is executed after the device is up for a new peer.
PeerDefPostUp string `json:"PeerDefPostUp"`
// PeerDefPreDown specifies the default action that is executed before the device is down for a new peer.
PeerDefPreDown string `json:"PeerDefPreDown"`
// PeerDefPostDown specifies the default action that is executed after the device is down for a new peer.
PeerDefPostDown string `json:"PeerDefPostDown"`
// Calculated values
// EnabledPeers is the number of enabled peers for this interface. Only enabled peers are able to connect.
EnabledPeers int `json:"EnabledPeers" readonly:"true"`
// TotalPeers is the total number of peers for this interface.
TotalPeers int `json:"TotalPeers" readonly:"true"`
}
func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
iface := &Interface{
Identifier: string(src.Identifier),
DisplayName: src.DisplayName,
Mode: string(src.Type),
PrivateKey: src.PrivateKey,
PublicKey: src.PublicKey,
Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason,
SaveConfig: src.SaveConfig,
ListenPort: src.ListenPort,
Addresses: domain.CidrsToStringSlice(src.Addresses),
Dns: internal.SliceString(src.DnsStr),
DnsSearch: internal.SliceString(src.DnsSearchStr),
Mtu: src.Mtu,
FirewallMark: src.FirewallMark,
RoutingTable: src.RoutingTable,
PreUp: src.PreUp,
PostUp: src.PostUp,
PreDown: src.PreDown,
PostDown: src.PostDown,
PeerDefNetwork: internal.SliceString(src.PeerDefNetworkStr),
PeerDefDns: internal.SliceString(src.PeerDefDnsStr),
PeerDefDnsSearch: internal.SliceString(src.PeerDefDnsSearchStr),
PeerDefEndpoint: src.PeerDefEndpoint,
PeerDefAllowedIPs: internal.SliceString(src.PeerDefAllowedIPsStr),
PeerDefMtu: src.PeerDefMtu,
PeerDefPersistentKeepalive: src.PeerDefPersistentKeepalive,
PeerDefFirewallMark: src.PeerDefFirewallMark,
PeerDefRoutingTable: src.PeerDefRoutingTable,
PeerDefPreUp: src.PeerDefPreUp,
PeerDefPostUp: src.PeerDefPostUp,
PeerDefPreDown: src.PeerDefPreDown,
PeerDefPostDown: src.PeerDefPostDown,
EnabledPeers: 0,
TotalPeers: 0,
}
if len(peers) > 0 {
iface.TotalPeers = len(peers)
activePeers := 0
for _, peer := range peers {
if !peer.IsDisabled() {
activePeers++
}
}
iface.EnabledPeers = activePeers
}
return iface
}
func NewInterfaces(src []domain.Interface, srcPeers [][]domain.Peer) []Interface {
results := make([]Interface, len(src))
for i := range src {
results[i] = *NewInterface(&src[i], srcPeers[i])
}
return results
}
func NewDomainInterface(src *Interface) *domain.Interface {
now := time.Now()
cidrs, _ := domain.CidrsFromArray(src.Addresses)
res := &domain.Interface{
BaseModel: domain.BaseModel{},
Identifier: domain.InterfaceIdentifier(src.Identifier),
KeyPair: domain.KeyPair{
PrivateKey: src.PrivateKey,
PublicKey: src.PublicKey,
},
ListenPort: src.ListenPort,
Addresses: cidrs,
DnsStr: internal.SliceToString(src.Dns),
DnsSearchStr: internal.SliceToString(src.DnsSearch),
Mtu: src.Mtu,
FirewallMark: src.FirewallMark,
RoutingTable: src.RoutingTable,
PreUp: src.PreUp,
PostUp: src.PostUp,
PreDown: src.PreDown,
PostDown: src.PostDown,
SaveConfig: src.SaveConfig,
DisplayName: src.DisplayName,
Type: domain.InterfaceType(src.Mode),
DriverType: "", // currently unused
Disabled: nil, // set below
DisabledReason: src.DisabledReason,
PeerDefNetworkStr: internal.SliceToString(src.PeerDefNetwork),
PeerDefDnsStr: internal.SliceToString(src.PeerDefDns),
PeerDefDnsSearchStr: internal.SliceToString(src.PeerDefDnsSearch),
PeerDefEndpoint: src.PeerDefEndpoint,
PeerDefAllowedIPsStr: internal.SliceToString(src.PeerDefAllowedIPs),
PeerDefMtu: src.PeerDefMtu,
PeerDefPersistentKeepalive: src.PeerDefPersistentKeepalive,
PeerDefFirewallMark: src.PeerDefFirewallMark,
PeerDefRoutingTable: src.PeerDefRoutingTable,
PeerDefPreUp: src.PeerDefPreUp,
PeerDefPostUp: src.PeerDefPostUp,
PeerDefPreDown: src.PeerDefPreDown,
PeerDefPostDown: src.PeerDefPostDown,
}
if src.Disabled {
res.Disabled = &now
}
return res
}

View File

@ -0,0 +1,195 @@
package models
import (
"time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain"
)
const ExpiryDateTimeLayout = "\"2006-01-02\""
type ExpiryDate struct {
*time.Time
}
// UnmarshalJSON will unmarshal using 2006-01-02 layout
func (d *ExpiryDate) UnmarshalJSON(b []byte) error {
if len(b) == 0 || string(b) == "null" || string(b) == "\"\"" {
return nil
}
parsed, err := time.Parse(ExpiryDateTimeLayout, string(b))
if err != nil {
return err
}
if !parsed.IsZero() {
d.Time = &parsed
}
return nil
}
// MarshalJSON will marshal using 2006-01-02 layout
func (d *ExpiryDate) MarshalJSON() ([]byte, error) {
if d == nil || d.Time == nil {
return []byte("null"), nil
}
s := d.Format(ExpiryDateTimeLayout)
return []byte(s), nil
}
// Peer represents a WireGuard peer entry.
type Peer struct {
// Identifier is the unique identifier of the peer. It is always equal to the public key of the peer.
Identifier string `json:"Identifier" example:"xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=" binding:"required,len=44"`
// DisplayName is a nice display name / description for the peer.
DisplayName string `json:"DisplayName" example:"My Peer" binding:"omitempty,max=64"`
// UserIdentifier is the identifier of the user that owns the peer.
UserIdentifier string `json:"UserIdentifier" example:"uid-1234567"`
// InterfaceIdentifier is the identifier of the interface the peer is linked to.
InterfaceIdentifier string `json:"InterfaceIdentifier" binding:"required" example:"wg0"`
// Disabled is a flag that specifies if the peer is enabled or not. Disabled peers are not able to connect.
Disabled bool `json:"Disabled" example:"false"`
// DisabledReason is the reason why the peer has been disabled.
DisabledReason string `json:"DisabledReason" binding:"required_if=Disabled true" example:"This is a reason why the peer has been disabled."`
// ExpiresAt is the expiry date of the peer in YYYY-MM-DD format. An expired peer is not able to connect.
ExpiresAt ExpiryDate `json:"ExpiresAt,omitempty" binding:"omitempty,datetime=2006-01-02"`
// Notes is a note field for peers.
Notes string `json:"Notes" example:"This is a note for the peer."`
// Endpoint is the endpoint address of the peer.
Endpoint ConfigOption[string] `json:"Endpoint"`
// EndpointPublicKey is the endpoint public key.
EndpointPublicKey ConfigOption[string] `json:"EndpointPublicKey"`
// AllowedIPs is a list of allowed IP subnets for the peer.
AllowedIPs ConfigOption[[]string] `json:"AllowedIPs"`
// ExtraAllowedIPs is a list of additional allowed IP subnets for the peer. These allowed IP subnets are added on the server side.
ExtraAllowedIPs []string `json:"ExtraAllowedIPs"`
// PresharedKey is the optional pre-shared Key of the peer.
PresharedKey string `json:"PresharedKey" example:"yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=" binding:"omitempty,len=44"`
// PersistentKeepalive is the optional persistent keep-alive interval in seconds.
PersistentKeepalive ConfigOption[int] `json:"PersistentKeepalive" binding:"omitempty,gte=0"`
// PrivateKey is the private Key of the peer.
PrivateKey string `json:"PrivateKey" example:"yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=" binding:"required,len=44"`
// PublicKey is the public Key of the server peer.
PublicKey string `json:"PublicKey" example:"TrMvSoP4jYQlY6RIzBgbssQqY3vxI2Pi+y71lOWWXX0=" binding:"omitempty,len=44"`
// Mode is the peer interface type (server, client, any).
Mode string `json:"Mode" example:"client" binding:"omitempty,oneof=server client any"`
// Addresses is a list of IP addresses in CIDR format (both IPv4 and IPv6) for the peer.
Addresses []string `json:"Addresses" example:"10.11.12.2/24" binding:"omitempty,dive,cidr"`
// CheckAliveAddress is an optional ip address or DNS name that is used for ping checks.
CheckAliveAddress string `json:"CheckAliveAddress" binding:"omitempty,ip|fqdn" example:"1.1.1.1"`
// Dns is a list of DNS servers that should be set if the peer interface is up.
Dns ConfigOption[[]string] `json:"Dns"`
// DnsSearch is the dns search option string that should be set if the peer interface is up, will be appended to Dns servers.
DnsSearch ConfigOption[[]string] `json:"DnsSearch"`
// Mtu is the device MTU of the peer.
Mtu ConfigOption[int] `json:"Mtu"`
// FirewallMark is an optional firewall mark which is used to handle peer traffic.
FirewallMark ConfigOption[uint32] `json:"FirewallMark"`
// RoutingTable is an optional routing table which is used to route peer traffic.
RoutingTable ConfigOption[string] `json:"RoutingTable"`
// PreUp is an optional action that is executed before the device is up.
PreUp ConfigOption[string] `json:"PreUp"`
// PostUp is an optional action that is executed after the device is up.
PostUp ConfigOption[string] `json:"PostUp"`
// PreDown is an optional action that is executed before the device is down.
PreDown ConfigOption[string] `json:"PreDown"`
// PostDown is an optional action that is executed after the device is down.
PostDown ConfigOption[string] `json:"PostDown"`
}
func NewPeer(src *domain.Peer) *Peer {
return &Peer{
Identifier: string(src.Identifier),
DisplayName: src.DisplayName,
UserIdentifier: string(src.UserIdentifier),
InterfaceIdentifier: string(src.InterfaceIdentifier),
Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason,
ExpiresAt: ExpiryDate{src.ExpiresAt},
Notes: src.Notes,
Endpoint: ConfigOptionFromDomain(src.Endpoint),
EndpointPublicKey: ConfigOptionFromDomain(src.EndpointPublicKey),
AllowedIPs: StringSliceConfigOptionFromDomain(src.AllowedIPsStr),
ExtraAllowedIPs: internal.SliceString(src.ExtraAllowedIPsStr),
PresharedKey: string(src.PresharedKey),
PersistentKeepalive: ConfigOptionFromDomain(src.PersistentKeepalive),
PrivateKey: src.Interface.PrivateKey,
PublicKey: src.Interface.PublicKey,
Mode: string(src.Interface.Type),
Addresses: domain.CidrsToStringSlice(src.Interface.Addresses),
CheckAliveAddress: src.Interface.CheckAliveAddress,
Dns: StringSliceConfigOptionFromDomain(src.Interface.DnsStr),
DnsSearch: StringSliceConfigOptionFromDomain(src.Interface.DnsSearchStr),
Mtu: ConfigOptionFromDomain(src.Interface.Mtu),
FirewallMark: ConfigOptionFromDomain(src.Interface.FirewallMark),
RoutingTable: ConfigOptionFromDomain(src.Interface.RoutingTable),
PreUp: ConfigOptionFromDomain(src.Interface.PreUp),
PostUp: ConfigOptionFromDomain(src.Interface.PostUp),
PreDown: ConfigOptionFromDomain(src.Interface.PreDown),
PostDown: ConfigOptionFromDomain(src.Interface.PostDown),
}
}
func NewPeers(src []domain.Peer) []Peer {
results := make([]Peer, len(src))
for i := range src {
results[i] = *NewPeer(&src[i])
}
return results
}
func NewDomainPeer(src *Peer) *domain.Peer {
now := time.Now()
cidrs, _ := domain.CidrsFromArray(src.Addresses)
res := &domain.Peer{
BaseModel: domain.BaseModel{},
Endpoint: ConfigOptionToDomain(src.Endpoint),
EndpointPublicKey: ConfigOptionToDomain(src.EndpointPublicKey),
AllowedIPsStr: StringSliceConfigOptionToDomain(src.AllowedIPs),
ExtraAllowedIPsStr: internal.SliceToString(src.ExtraAllowedIPs),
PresharedKey: domain.PreSharedKey(src.PresharedKey),
PersistentKeepalive: ConfigOptionToDomain(src.PersistentKeepalive),
DisplayName: src.DisplayName,
Identifier: domain.PeerIdentifier(src.Identifier),
UserIdentifier: domain.UserIdentifier(src.UserIdentifier),
InterfaceIdentifier: domain.InterfaceIdentifier(src.InterfaceIdentifier),
Disabled: nil, // set below
DisabledReason: src.DisabledReason,
ExpiresAt: src.ExpiresAt.Time,
Notes: src.Notes,
Interface: domain.PeerInterfaceConfig{
KeyPair: domain.KeyPair{
PrivateKey: src.PrivateKey,
PublicKey: src.PublicKey,
},
Type: domain.InterfaceType(src.Mode),
Addresses: cidrs,
CheckAliveAddress: src.CheckAliveAddress,
DnsStr: StringSliceConfigOptionToDomain(src.Dns),
DnsSearchStr: StringSliceConfigOptionToDomain(src.DnsSearch),
Mtu: ConfigOptionToDomain(src.Mtu),
FirewallMark: ConfigOptionToDomain(src.FirewallMark),
RoutingTable: ConfigOptionToDomain(src.RoutingTable),
PreUp: ConfigOptionToDomain(src.PreUp),
PostUp: ConfigOptionToDomain(src.PostUp),
PreDown: ConfigOptionToDomain(src.PreDown),
PostDown: ConfigOptionToDomain(src.PostDown),
},
}
if src.Disabled {
res.Disabled = &now
}
return res
}

View File

@ -0,0 +1,75 @@
package models
import "github.com/h44z/wg-portal/internal/domain"
// UserInformation represents the information about a user and its linked peers.
type UserInformation struct {
// UserIdentifier is the unique identifier of the user.
UserIdentifier string `json:"UserIdentifier" example:"uid-1234567"`
// PeerCount is the number of peers linked to the user.
PeerCount int `json:"PeerCount" example:"2"`
// Peers is a list of peers linked to the user.
Peers []UserInformationPeer `json:"Peers"`
}
// UserInformationPeer represents the information about a peer.
type UserInformationPeer struct {
// Identifier is the unique identifier of the peer. It equals the public key of the peer.
Identifier string `json:"Identifier" example:"peer-1234567"`
// DisplayName is a user-defined description of the peer.
DisplayName string `json:"DisplayName" example:"My iPhone"`
// IPAddresses is a list of IP addresses in CIDR format assigned to the peer.
IpAddresses []string `json:"IpAddresses" example:"10.11.12.2/24"`
// IsDisabled is a flag that specifies if the peer is enabled or not. Disabled peers are not able to connect.
IsDisabled bool `json:"IsDisabled,omitempty" example:"true"`
// InterfaceIdentifier is the unique identifier of the WireGuard Portal device the peer is connected to.
InterfaceIdentifier string `json:"InterfaceIdentifier" example:"wg0"`
}
func NewUserInformation(user *domain.User, peers []domain.Peer) *UserInformation {
if user == nil {
return &UserInformation{}
}
ui := &UserInformation{
UserIdentifier: string(user.Identifier),
PeerCount: len(peers),
}
for _, peer := range peers {
ui.Peers = append(ui.Peers, NewUserInformationPeer(peer))
}
if len(ui.Peers) == 0 {
ui.Peers = []UserInformationPeer{} // Ensure that the JSON output is an empty array instead of null.
}
return ui
}
func NewUserInformationPeer(peer domain.Peer) UserInformationPeer {
up := UserInformationPeer{
Identifier: string(peer.Identifier),
DisplayName: peer.DisplayName,
IpAddresses: domain.CidrsToStringSlice(peer.Interface.Addresses),
IsDisabled: peer.IsDisabled(),
InterfaceIdentifier: string(peer.InterfaceIdentifier),
}
return up
}
// ProvisioningRequest represents a request to provision a new peer.
type ProvisioningRequest struct {
// InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to.
InterfaceIdentifier string `json:"InterfaceIdentifier" example:"wg0" binding:"required"`
// UserIdentifier is the identifier of the user the peer should be linked to.
// If no user identifier is set, the authenticated user is used.
UserIdentifier string `json:"UserIdentifier" example:"uid-1234567"`
// PublicKey is the optional public key of the peer. If no public key is set, a new key pair is generated.
PublicKey string `json:"PublicKey" example:"xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=" binding:"omitempty,len=44"`
// PresharedKey is the optional pre-shared key of the peer. If no pre-shared key is set, a new key is generated.
PresharedKey string `json:"PresharedKey" example:"yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=" binding:"omitempty,len=44"`
}

View File

@ -0,0 +1,125 @@
package models
import (
"time"
"github.com/h44z/wg-portal/internal/domain"
)
// User represents a user in the system.
type User struct {
// The unique identifier of the user.
Identifier string `json:"Identifier" binding:"required,max=64" example:"uid-1234567"`
// The email address of the user. This field is optional.
Email string `json:"Email" binding:"omitempty,email" example:"test@test.com"`
// The source of the user. This field is optional.
Source string `json:"Source" binding:"oneof=db" example:"db"`
// The name of the authentication provider. This field is read-only.
ProviderName string `json:"ProviderName,omitempty" readonly:"true" example:""`
// If this field is set, the user is an admin.
IsAdmin bool `json:"IsAdmin" binding:"required" example:"false"`
// The first name of the user. This field is optional.
Firstname string `json:"Firstname" example:"Max"`
// The last name of the user. This field is optional.
Lastname string `json:"Lastname" example:"Muster"`
// The phone number of the user. This field is optional.
Phone string `json:"Phone" example:"+1234546789"`
// The department of the user. This field is optional.
Department string `json:"Department" example:"Software Development"`
// Additional notes about the user. This field is optional.
Notes string `json:"Notes" example:"some sample notes"`
// The password of the user. This field is never populated on read operations.
Password string `json:"Password,omitempty" binding:"omitempty,min=16,max=64" example:""`
// If this field is set, the user is disabled.
Disabled bool `json:"Disabled" example:"false"`
// The reason why the user has been disabled.
DisabledReason string `json:"DisabledReason" binding:"required_if=Disabled true" example:""`
// If this field is set, the user is locked and thus unable to log in to WireGuard Portal.
Locked bool `json:"Locked" example:"false"`
// The reason why the user has been locked.
LockedReason string `json:"LockedReason" binding:"required_if=Locked true" example:""`
// The API token of the user. This field is never populated on bulk read operations.
ApiToken string `json:"ApiToken,omitempty" binding:"omitempty,min=32,max=64" example:""`
// If this field is set, the user is allowed to use the RESTful API. This field is read-only.
ApiEnabled bool `json:"ApiEnabled" readonly:"true" example:"false"`
// The number of peers linked to the user. This field is read-only.
PeerCount int `json:"PeerCount" readonly:"true" example:"2"`
}
func NewUser(src *domain.User, exposeCredentials bool) *User {
u := &User{
Identifier: string(src.Identifier),
Email: src.Email,
Source: string(src.Source),
ProviderName: src.ProviderName,
IsAdmin: src.IsAdmin,
Firstname: src.Firstname,
Lastname: src.Lastname,
Phone: src.Phone,
Department: src.Department,
Notes: src.Notes,
Password: "", // never fill password
Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason,
Locked: src.IsLocked(),
LockedReason: src.LockedReason,
ApiToken: "", // by default, do not expose API token
ApiEnabled: src.IsApiEnabled(),
PeerCount: src.LinkedPeerCount,
}
if exposeCredentials {
u.ApiToken = src.ApiToken
}
return u
}
func NewUsers(src []domain.User) []User {
results := make([]User, len(src))
for i := range src {
results[i] = *NewUser(&src[i], false)
}
return results
}
func NewDomainUser(src *User) *domain.User {
now := time.Now()
res := &domain.User{
Identifier: domain.UserIdentifier(src.Identifier),
Email: src.Email,
Source: domain.UserSource(src.Source),
ProviderName: src.ProviderName,
IsAdmin: src.IsAdmin,
Firstname: src.Firstname,
Lastname: src.Lastname,
Phone: src.Phone,
Department: src.Department,
Notes: src.Notes,
Password: domain.PrivateString(src.Password),
Disabled: nil, // set below
DisabledReason: src.DisabledReason,
Locked: nil, // set below
LockedReason: src.LockedReason,
}
if src.ApiToken != "" {
res.ApiToken = src.ApiToken
res.ApiTokenCreated = &now
}
if src.Disabled {
res.Disabled = &now
}
if src.Locked {
res.Locked = &now
}
return res
}

View File

@ -22,9 +22,19 @@ type App struct {
StatisticsCollector
ConfigFileManager
MailManager
ApiV1Manager
}
func New(cfg *config.Config, bus evbus.MessageBus, authenticator Authenticator, users UserManager, wireGuard WireGuardManager, stats StatisticsCollector, cfgFiles ConfigFileManager, mailer MailManager) (*App, error) {
func New(
cfg *config.Config,
bus evbus.MessageBus,
authenticator Authenticator,
users UserManager,
wireGuard WireGuardManager,
stats StatisticsCollector,
cfgFiles ConfigFileManager,
mailer MailManager,
) (*App, error) {
a := &App{
Config: cfg,
@ -60,7 +70,7 @@ func New(cfg *config.Config, bus evbus.MessageBus, authenticator Authenticator,
}
func (a *App) Startup(ctx context.Context) error {
a.UserManager.StartBackgroundJobs(ctx)
a.StatisticsCollector.StartBackgroundJobs(ctx)
a.WireGuardManager.StartBackgroundJobs(ctx)

View File

@ -1,6 +1,8 @@
package app
const TopicUserCreated = "user:created"
const TopicUserApiEnabled = "user:api:enabled"
const TopicUserApiDisabled = "user:api:disabled"
const TopicUserRegistered = "user:registered"
const TopicUserDisabled = "user:disabled"
const TopicUserEnabled = "user:enabled"

View File

@ -2,8 +2,9 @@ package app
import (
"context"
"github.com/h44z/wg-portal/internal/domain"
"io"
"github.com/h44z/wg-portal/internal/domain"
)
type Authenticator interface {
@ -23,6 +24,8 @@ type UserManager interface {
UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error)
CreateUser(ctx context.Context, user *domain.User) (*domain.User, error)
DeleteUser(ctx context.Context, id domain.UserIdentifier) error
ActivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
}
type WireGuardManager interface {
@ -43,7 +46,11 @@ type WireGuardManager interface {
PreparePeer(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Peer, error)
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error)
CreatePeer(ctx context.Context, p *domain.Peer) (*domain.Peer, error)
CreateMultiplePeers(ctx context.Context, id domain.InterfaceIdentifier, r *domain.PeerCreationRequest) ([]domain.Peer, error)
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
@ -63,3 +70,7 @@ type ConfigFileManager interface {
type MailManager interface {
SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error
}
type ApiV1Manager interface {
ApiV1GetUsers(ctx context.Context) ([]domain.User, error)
}

View File

@ -2,11 +2,13 @@ package users
import (
"context"
"github.com/h44z/wg-portal/internal/domain"
)
type UserDatabaseRepo interface {
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
GetAllUsers(ctx context.Context) ([]domain.User, error)
FindUsers(ctx context.Context, search string) ([]domain.User, error)
SaveUser(ctx context.Context, id domain.UserIdentifier, updateFunc func(u *domain.User) (*domain.User, error)) error

View File

@ -8,6 +8,7 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal"
@ -101,7 +102,7 @@ func (m Manager) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain
user, err := m.users.GetUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("unable to load peer %s: %w", id, err)
return nil, fmt.Errorf("unable to load user %s: %w", id, err)
}
peers, _ := m.peers.GetUserPeers(ctx, id) // ignore error, list will be empty in error case
@ -110,6 +111,24 @@ func (m Manager) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain
return user, nil
}
func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
user, err := m.users.GetUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("unable to load user for email %s: %w", email, err)
}
if err := domain.ValidateUserAccessRights(ctx, user.Identifier); err != nil {
return nil, err
}
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case
user.LinkedPeerCount = len(peers)
return user, nil
}
func (m Manager) GetAllUsers(ctx context.Context) ([]domain.User, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
@ -193,7 +212,7 @@ func (m Manager) CreateUser(ctx context.Context, user *domain.User) (*domain.Use
return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
}
if existingUser != nil {
return nil, fmt.Errorf("user %s already exists", user.Identifier)
return nil, errors.Join(fmt.Errorf("user %s already exists", user.Identifier), domain.ErrDuplicateEntry)
}
if err := m.validateCreation(ctx, user); err != nil {
@ -240,6 +259,59 @@ func (m Manager) DeleteUser(ctx context.Context, id domain.UserIdentifier) error
return nil
}
func (m Manager) ActivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
user, err := m.users.GetUser(ctx, id)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("unable to find user %s: %w", id, err)
}
if err := m.validateApiChange(ctx, user); err != nil {
return nil, err
}
now := time.Now()
user.ApiToken = uuid.New().String()
user.ApiTokenCreated = &now
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
user.CopyCalculatedAttributes(u)
return user, nil
})
if err != nil {
return nil, fmt.Errorf("update failure: %w", err)
}
m.bus.Publish(app.TopicUserApiEnabled, user)
return user, nil
}
func (m Manager) DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
user, err := m.users.GetUser(ctx, id)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("unable to find user %s: %w", id, err)
}
if err := m.validateApiChange(ctx, user); err != nil {
return nil, err
}
user.ApiToken = ""
user.ApiTokenCreated = nil
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
user.CopyCalculatedAttributes(u)
return user, nil
})
if err != nil {
return nil, fmt.Errorf("update failure: %w", err)
}
m.bus.Publish(app.TopicUserApiDisabled, user)
return user, nil
}
func (m Manager) validateModifications(ctx context.Context, old, new *domain.User) error {
currentUser := domain.GetUserInfo(ctx)
@ -248,27 +320,27 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Use
}
if err := old.EditAllowed(new); err != nil {
return fmt.Errorf("no access: %w", err)
return errors.Join(fmt.Errorf("no access: %w", err), domain.ErrInvalidData)
}
if err := old.CanChangePassword(); err != nil && string(new.Password) != "" {
return fmt.Errorf("no access: %w", err)
return errors.Join(fmt.Errorf("no access: %w", err), domain.ErrInvalidData)
}
if currentUser.Id == old.Identifier && old.IsAdmin && !new.IsAdmin {
return fmt.Errorf("cannot remove own admin rights")
return fmt.Errorf("cannot remove own admin rights: %w", domain.ErrInvalidData)
}
if currentUser.Id == old.Identifier && new.IsDisabled() {
return fmt.Errorf("cannot disable own user")
return fmt.Errorf("cannot disable own user: %w", domain.ErrInvalidData)
}
if currentUser.Id == old.Identifier && new.IsLocked() {
return fmt.Errorf("cannot lock own user")
return fmt.Errorf("cannot lock own user: %w", domain.ErrInvalidData)
}
if old.Source != new.Source {
return fmt.Errorf("cannot change user source")
return fmt.Errorf("cannot change user source: %w", domain.ErrInvalidData)
}
return nil
@ -282,19 +354,32 @@ func (m Manager) validateCreation(ctx context.Context, new *domain.User) error {
}
if new.Identifier == "" {
return fmt.Errorf("invalid user identifier")
return fmt.Errorf("invalid user identifier: %w", domain.ErrInvalidData)
}
if new.Identifier == "all" { // the all user identifier collides with the rest api routes
return fmt.Errorf("reserved user identifier")
if new.Identifier == "all" { // the 'all' user identifier collides with the rest api routes
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}
if new.Identifier == "new" { // the 'new' user identifier collides with the rest api routes
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}
if new.Identifier == "id" { // the 'id' user identifier collides with the rest api routes
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}
if new.Identifier == domain.CtxSystemAdminId || new.Identifier == domain.CtxUnknownUserId {
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}
if new.Source != domain.UserSourceDatabase {
return fmt.Errorf("invalid user source: %s", new.Source)
return fmt.Errorf("invalid user source: %s, only %s is allowed: %w",
new.Source, domain.UserSourceDatabase, domain.ErrInvalidData)
}
if string(new.Password) == "" {
return fmt.Errorf("invalid password")
return fmt.Errorf("invalid password: %w", domain.ErrInvalidData)
}
return nil
@ -304,15 +389,25 @@ func (m Manager) validateDeletion(ctx context.Context, del *domain.User) error {
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
return domain.ErrNoPermission
}
if err := del.DeleteAllowed(); err != nil {
return fmt.Errorf("no access: %w", err)
return errors.Join(fmt.Errorf("no access: %w", err), domain.ErrInvalidData)
}
if currentUser.Id == del.Identifier {
return fmt.Errorf("cannot delete own user")
return fmt.Errorf("cannot delete own user: %w", domain.ErrInvalidData)
}
return nil
}
func (m Manager) validateApiChange(ctx context.Context, user *domain.User) error {
currentUser := domain.GetUserInfo(ctx)
if currentUser.Id != user.Identifier {
return fmt.Errorf("cannot change API access of user: %w", domain.ErrNoPermission)
}
return nil

View File

@ -357,7 +357,7 @@ func (m Manager) CreateInterface(ctx context.Context, in *domain.Interface) (*do
return nil, fmt.Errorf("unable to load existing interface %s: %w", in.Identifier, err)
}
if existingInterface != nil {
return nil, fmt.Errorf("interface %s already exists", in.Identifier)
return nil, fmt.Errorf("interface %s already exists: %w", in.Identifier, domain.ErrDuplicateEntry)
}
if err := m.validateInterfaceCreation(ctx, existingInterface, in); err != nil {
@ -825,6 +825,13 @@ func (m Manager) validateInterfaceCreation(ctx context.Context, old, new *domain
return fmt.Errorf("insufficient permissions")
}
// validate public key if it is set
if new.PublicKey != "" && new.PrivateKey != "" {
if domain.PublicKeyFromPrivateKey(new.PrivateKey) != new.PublicKey {
return fmt.Errorf("invalid public key for given privatekey: %w", domain.ErrInvalidData)
}
}
return nil
}

View File

@ -6,7 +6,6 @@ import (
"fmt"
"time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/domain"
"github.com/sirupsen/logrus"
@ -34,9 +33,9 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti
}
peer.UserIdentifier = userId
peer.DisplayName = fmt.Sprintf("Default Peer %s", internal.TruncateString(string(peer.Identifier), 8))
peer.Notes = fmt.Sprintf("Default peer created for user %s", userId)
peer.AutomaticallyCreated = true
peer.GenerateDisplayName("Default")
newPeers = append(newPeers, *peer)
}
@ -108,7 +107,6 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier)
ExtraAllowedIPsStr: "",
PresharedKey: pk,
PersistentKeepalive: domain.NewConfigOption(iface.PeerDefPersistentKeepalive, true),
DisplayName: fmt.Sprintf("Peer %s", internal.TruncateString(string(peerId), 8)),
Identifier: peerId,
UserIdentifier: currentUser.Id,
InterfaceIdentifier: iface.Identifier,
@ -132,6 +130,7 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier)
PostDown: domain.NewConfigOption(iface.PeerDefPostDown, true),
},
}
freshPeer.GenerateDisplayName("")
return freshPeer, nil
}
@ -159,7 +158,7 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
return nil, fmt.Errorf("unable to load existing peer %s: %w", peer.Identifier, err)
}
if existingPeer != nil {
return nil, fmt.Errorf("peer %s already exists", peer.Identifier)
return nil, fmt.Errorf("peer %s already exists: %w", peer.Identifier, domain.ErrDuplicateEntry)
}
if err := m.validatePeerCreation(ctx, existingPeer, peer); err != nil {
@ -234,6 +233,15 @@ func (m Manager) UpdatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
if existingPeer.Identifier != domain.PeerIdentifier(peer.Interface.PublicKey) {
peer.Identifier = domain.PeerIdentifier(peer.Interface.PublicKey) // set new identifier
// check for already existing peer with new identifier
duplicatePeer, err := m.db.GetPeer(ctx, peer.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("unable to load existing peer %s: %w", peer.Identifier, err)
}
if duplicatePeer != nil {
return nil, fmt.Errorf("peer %s already exists: %w", peer.Identifier, domain.ErrDuplicateEntry)
}
// delete old peer
err = m.DeletePeer(ctx, existingPeer.Identifier)
if err != nil {
@ -431,7 +439,7 @@ func (m Manager) validatePeerModifications(ctx context.Context, old, new *domain
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
return domain.ErrNoPermission
}
return nil
@ -441,11 +449,16 @@ func (m Manager) validatePeerCreation(ctx context.Context, old, new *domain.Peer
currentUser := domain.GetUserInfo(ctx)
if new.Identifier == "" {
return fmt.Errorf("invalid peer identifier")
return fmt.Errorf("invalid peer identifier: %w", domain.ErrInvalidData)
}
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
return domain.ErrNoPermission
}
_, err := m.db.GetInterface(ctx, new.InterfaceIdentifier)
if err != nil {
return fmt.Errorf("invalid interface: %w", domain.ErrInvalidData)
}
return nil
@ -455,7 +468,7 @@ func (m Manager) validatePeerDeletion(ctx context.Context, del *domain.Peer) err
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
return domain.ErrNoPermission
}
return nil

View File

@ -39,6 +39,7 @@ type Config struct {
ExpiryCheckInterval time.Duration `yaml:"expiry_check_interval"`
RulePrioOffset int `yaml:"rule_prio_offset"`
RouteTableOffset int `yaml:"route_table_offset"`
ApiAdminOnly bool `yaml:"api_admin_only"` // if true, only admin users can access the API
} `yaml:"advanced"`
Statistics struct {
@ -126,6 +127,7 @@ func defaultConfig() *Config {
cfg.Advanced.ExpiryCheckInterval = 15 * time.Minute
cfg.Advanced.RulePrioOffset = 20000
cfg.Advanced.RouteTableOffset = 20000
cfg.Advanced.ApiAdminOnly = true
cfg.Statistics.UsePingChecks = true
cfg.Statistics.PingCheckWorkers = 10

View File

@ -3,6 +3,7 @@ package domain
import (
"context"
"fmt"
"github.com/sirupsen/logrus"
"github.com/gin-gonic/gin"
@ -28,6 +29,7 @@ func (u *ContextUserInfo) UserId() string {
return string(u.Id)
}
// DefaultContextUserInfo returns a default context user info.
func DefaultContextUserInfo() *ContextUserInfo {
return &ContextUserInfo{
Id: CtxUnknownUserId,
@ -35,6 +37,7 @@ func DefaultContextUserInfo() *ContextUserInfo {
}
}
// SystemAdminContextUserInfo returns a context user info for the system admin.
func SystemAdminContextUserInfo() *ContextUserInfo {
return &ContextUserInfo{
Id: CtxSystemAdminId,
@ -42,6 +45,7 @@ func SystemAdminContextUserInfo() *ContextUserInfo {
}
}
// SetUserInfoFromGin sets the user info from the gin context to the request context.
func SetUserInfoFromGin(c *gin.Context) context.Context {
ginUserInfo, exists := c.Get(CtxUserInfo)
@ -56,11 +60,13 @@ func SetUserInfoFromGin(c *gin.Context) context.Context {
return ctx
}
// SetUserInfo sets the user info in the context.
func SetUserInfo(ctx context.Context, info *ContextUserInfo) context.Context {
ctx = context.WithValue(ctx, CtxUserInfo, info)
return ctx
}
// GetUserInfo returns the user info from the context.
func GetUserInfo(ctx context.Context) *ContextUserInfo {
rawInfo := ctx.Value(CtxUserInfo)
if rawInfo == nil {
@ -74,6 +80,8 @@ func GetUserInfo(ctx context.Context) *ContextUserInfo {
return DefaultContextUserInfo()
}
// ValidateUserAccessRights checks if the current user has access rights to the requested user.
// If the user is an admin, access is granted.
func ValidateUserAccessRights(ctx context.Context, requiredUser UserIdentifier) error {
sessionUser := GetUserInfo(ctx)
@ -86,9 +94,10 @@ func ValidateUserAccessRights(ctx context.Context, requiredUser UserIdentifier)
}
logrus.Warnf("insufficient permissions for %s (want %s), stack: %s", sessionUser.Id, requiredUser, GetStackTrace())
return fmt.Errorf("insufficient permissions")
return ErrNoPermission
}
// ValidateAdminAccessRights checks if the current user has admin access rights.
func ValidateAdminAccessRights(ctx context.Context) error {
sessionUser := GetUserInfo(ctx)
@ -97,5 +106,5 @@ func ValidateAdminAccessRights(ctx context.Context) error {
}
logrus.Warnf("insufficient admin permissions for %s, stack: %s", sessionUser.Id, GetStackTrace())
return fmt.Errorf("insufficient permissions")
return ErrNoPermission
}

View File

@ -7,6 +7,9 @@ import (
var ErrNotFound = errors.New("record not found")
var ErrNotUnique = errors.New("record not unique")
var ErrNoPermission = errors.New("no permission")
var ErrDuplicateEntry = errors.New("duplicate entry")
var ErrInvalidData = errors.New("invalid data")
// GetStackTrace returns a stack trace of the current goroutine. The stack trace has at most 1024 bytes.
func GetStackTrace() string {

View File

@ -120,6 +120,13 @@ func (p *Peer) ApplyInterfaceDefaults(in *Interface) {
p.Interface.PostDown.TrySetValue(in.PeerDefPostDown)
}
func (p *Peer) GenerateDisplayName(prefix string) {
if prefix != "" {
prefix = fmt.Sprintf("%s ", strings.TrimSpace(prefix)) // add a space after the prefix
}
p.DisplayName = fmt.Sprintf("%sPeer %s", prefix, internal.TruncateString(string(p.Identifier), 8))
}
type PeerInterfaceConfig struct {
KeyPair // private/public Key of the peer

View File

@ -1,6 +1,7 @@
package domain
import (
"crypto/subtle"
"errors"
"time"
@ -42,6 +43,10 @@ type User struct {
Locked *time.Time `gorm:"index;column:locked"` // if this field is set, the user is locked and can no longer login (WireGuard peers still can connect)
LockedReason string // the reason why the user has been locked
// API token for REST API access
ApiToken string `form:"api_token" binding:"omitempty"`
ApiTokenCreated *time.Time
LinkedPeerCount int `gorm:"-"`
}
@ -56,6 +61,14 @@ func (u *User) IsLocked() bool {
return u.Locked != nil
}
func (u *User) IsApiEnabled() bool {
if u.ApiToken != "" {
return true
}
return false
}
func (u *User) CanChangePassword() error {
if u.Source == UserSourceDatabase {
return nil
@ -115,6 +128,18 @@ func (u *User) CheckPassword(password string) error {
return nil
}
func (u *User) CheckApiToken(token string) error {
if !u.IsApiEnabled() {
return errors.New("api access disabled")
}
if res := subtle.ConstantTimeCompare([]byte(u.ApiToken), []byte(token)); res != 1 {
return errors.New("wrong token")
}
return nil
}
func (u *User) HashPassword() error {
if u.Password == "" {
return nil // nothing to hash