mirror of
https://github.com/h44z/wg-portal.git
synced 2025-10-05 16:06:17 +00:00
Compare commits
13 Commits
v2.0.0-rc.
...
v2.0.0-rc.
Author | SHA1 | Date | |
---|---|---|---|
|
b4aa6f8ef3 | ||
|
020ebb64e7 | ||
|
923d4a6188 | ||
|
2b46dca770 | ||
|
b9c4ca04f5 | ||
|
dddf0c475b | ||
|
fe60a5ab9b | ||
|
e176e07f7d | ||
|
b06c03ef8e | ||
|
6b0b78d749 | ||
|
62f3c8d4a1 | ||
|
fbcb22198c | ||
|
2c443a4a9b |
8
.github/workflows/docker-publish.yml
vendored
8
.github/workflows/docker-publish.yml
vendored
@@ -64,10 +64,10 @@ jobs:
|
||||
# major and major.minor tags are not available for alpha or beta releases
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
# add v{{major}} tag, even for beta releases
|
||||
type=match,pattern=(v\d),group=1,enable=${{ contains(github.ref, 'beta') }}
|
||||
# add {{major}} tag, even for beta releases
|
||||
type=match,pattern=v(\d),group=1,enable=${{ contains(github.ref, 'beta') }}
|
||||
# add v{{major}} tag, even for beta or release-canidate releases
|
||||
type=match,pattern=(v\d),group=1,enable=${{ contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
|
||||
# add {{major}} tag, even for beta releases or release-canidate releases
|
||||
type=match,pattern=v(\d),group=1,enable=${{ contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
|
||||
# set latest tag for default branch
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ ssh.key
|
||||
wg_portal.db
|
||||
sqlite.db
|
||||
/config.yml
|
||||
/config.yaml
|
||||
/config/
|
||||
venv/
|
||||
.cache/
|
||||
|
@@ -52,7 +52,7 @@ COPY --from=builder /build/dist/wg-portal /
|
||||
######
|
||||
FROM alpine:3.19
|
||||
# Install OS-level dependencies
|
||||
RUN apk add --no-cache bash curl iptables nftables openresolv
|
||||
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools
|
||||
# Setup timezone
|
||||
ENV TZ=UTC
|
||||
# Copy binaries
|
||||
|
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
evbus "github.com/vardius/message-bus"
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/adapters"
|
||||
@@ -41,6 +42,8 @@ func main() {
|
||||
|
||||
cfg.LogStartupValues()
|
||||
|
||||
dbEncryptedSerializer := app.NewGormEncryptedStringSerializer(cfg.Database.EncryptionPassphrase)
|
||||
schema.RegisterSerializer("encstr", dbEncryptedSerializer)
|
||||
rawDb, err := adapters.NewDatabase(cfg.Database)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
|
@@ -12,6 +12,7 @@ services:
|
||||
- NET_ADMIN
|
||||
network_mode: "host"
|
||||
volumes:
|
||||
# left side is the host path, right side is the container path
|
||||
- /etc/wireguard:/etc/wireguard
|
||||
- ./data:/app/data
|
||||
- ./config:/app/config
|
||||
|
@@ -31,6 +31,7 @@ database:
|
||||
debug: true
|
||||
type: sqlite
|
||||
dsn: data/sqlite.db
|
||||
encryption_passphrase: change-this-s3cr3t-encryption-passphrase
|
||||
```
|
||||
|
||||
## LDAP Authentication and Synchronization
|
||||
|
@@ -1,7 +1,7 @@
|
||||
This page provides an overview of **all available configuration options** for WireGuard Portal.
|
||||
|
||||
You can supply these configurations in a **YAML** file (e.g. `config.yaml`) when starting the Portal.
|
||||
The path of the configuration file defaults to **config/config.yml** in the working directory of the executable.
|
||||
The path of the configuration file defaults to **config/config.yaml** (or config/config.yml) in the working directory of the executable.
|
||||
It is possible to override configuration filepath using the environment variable `WG_PORTAL_CONFIG`.
|
||||
For example: `WG_PORTAL_CONFIG=/etc/wg-portal/config.yaml ./wg-portal`.
|
||||
Also, environment variable substitution in config file is supported. Refer to [syntax](https://github.com/a8m/envsubst?tab=readme-ov-file#docs).
|
||||
@@ -39,7 +39,7 @@ advanced:
|
||||
|
||||
database:
|
||||
debug: false
|
||||
slow_query_threshold: 0
|
||||
slow_query_threshold: "0"
|
||||
type: sqlite
|
||||
dsn: data/sqlite.db
|
||||
|
||||
@@ -214,13 +214,15 @@ Additional or more specialized configuration options for logging and interface c
|
||||
Configuration for the underlying database used by WireGuard Portal.
|
||||
Supported databases include SQLite, MySQL, Microsoft SQL Server, and Postgres.
|
||||
|
||||
If sensitive values (like private keys) should be stored in an encrypted format, set the `encryption_passphrase` option.
|
||||
|
||||
### `debug`
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, logs all database statements (verbose).
|
||||
|
||||
### `slow_query_threshold`
|
||||
- **Default:** 0
|
||||
- **Description:** A time threshold (e.g., `100ms`) above which queries are considered slow and logged as warnings. If empty or zero, slow query logging is disabled. Format uses `s`, `ms` for seconds, milliseconds, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
|
||||
- **Default:** "0"
|
||||
- **Description:** A time threshold (e.g., `100ms`) above which queries are considered slow and logged as warnings. If zero, slow query logging is disabled. Format uses `s`, `ms` for seconds, milliseconds, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration). The value must be a string.
|
||||
|
||||
### `type`
|
||||
- **Default:** `sqlite`
|
||||
@@ -234,6 +236,12 @@ Supported databases include SQLite, MySQL, Microsoft SQL Server, and Postgres.
|
||||
user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
|
||||
```
|
||||
|
||||
### `encryption_passphrase`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** Passphrase for encrypting sensitive values such as private keys in the database. Encryption is only applied if this passphrase is set.
|
||||
**Important:** Once you enable encryption by setting this passphrase, you cannot disable it or change it afterward.
|
||||
New or updated records will be encrypted; existing data remains in plaintext until it’s next modified.
|
||||
|
||||
---
|
||||
|
||||
## Statistics
|
||||
@@ -274,7 +282,7 @@ Controls how WireGuard Portal collects and reports usage statistics, including p
|
||||
|
||||
### `listening_address`
|
||||
- **Default:** `:8787`
|
||||
- **Description:** Address and port for the integrated Prometheus metric server (e.g., `:8787`).
|
||||
- **Description:** Address and port for the integrated Prometheus metric server (e.g., `:8787` or `127.0.0.1:8888`).
|
||||
|
||||
---
|
||||
|
||||
|
@@ -31,4 +31,4 @@ sudo install wg-portal /opt/wg-portal/
|
||||
## Unreleased
|
||||
|
||||
Unreleased versions could be downloaded from
|
||||
[GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster) artifacs also.
|
||||
[GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster) artifacts also.
|
||||
|
@@ -1,8 +1,13 @@
|
||||
## Image Usage
|
||||
|
||||
The preferred way to start WireGuard Portal as Docker container is to use Docker Compose.
|
||||
The WireGuard Portal Docker image is available on both [Docker Hub](https://hub.docker.com/r/wgportal/wg-portal) and [GitHub Container Registry](https://github.com/h44z/wg-portal/pkgs/container/wg-portal).
|
||||
It is built on the official Alpine Linux base image and comes pre-packaged with all necessary WireGuard dependencies.
|
||||
|
||||
A sample docker-compose.yml:
|
||||
This container allows you to establish WireGuard VPN connections without relying on a host system that supports WireGuard or using the `linuxserver/wireguard` Docker image.
|
||||
|
||||
The recommended method for deploying WireGuard Portal is via Docker Compose for ease of configuration and management.
|
||||
|
||||
A sample docker-compose.yml (managing WireGuard interfaces directly on the host) is provided below:
|
||||
|
||||
```yaml
|
||||
--8<-- "docker-compose.yml::17"
|
||||
@@ -12,14 +17,102 @@ By default, the webserver is listening on port **8888**.
|
||||
|
||||
Volumes for `/app/data` and `/app/config` should be used ensure data persistence across container restarts.
|
||||
|
||||
## WireGuard Interface Handling
|
||||
|
||||
WireGuard Portal supports managing WireGuard interfaces through three distinct deployment methods, providing flexibility based on your system architecture and operational preferences:
|
||||
|
||||
- **Directly on the host system**:
|
||||
WireGuard Portal can control WireGuard interfaces natively on the host, without using containers.
|
||||
This setup is ideal for environments where direct access to system networking is preferred.
|
||||
To use this method, you need to set the network mode to `host` in your docker-compose.yml file.
|
||||
```yaml
|
||||
services:
|
||||
wg-portal:
|
||||
...
|
||||
network_mode: "host"
|
||||
...
|
||||
```
|
||||
|
||||
- **Within the WireGuard Portal Docker container**:
|
||||
WireGuard interfaces can be managed directly from within the WireGuard Portal container itself.
|
||||
This is the recommended approach when running WireGuard Portal via Docker, as it encapsulates all functionality in a single, portable container without requiring a separate WireGuard host or image.
|
||||
```yaml
|
||||
services:
|
||||
wg-portal:
|
||||
image: wgportal/wg-portal:latest
|
||||
container_name: wg-portal
|
||||
...
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
ports:
|
||||
# host port : container port
|
||||
# WireGuard port, needs to match the port in wg-portal interface config (add one port mapping for each interface)
|
||||
- "51820:51820/udp"
|
||||
# Web UI port
|
||||
- "8888:8888/tcp"
|
||||
sysctls:
|
||||
- net.ipv4.conf.all.src_valid_mark=1
|
||||
volumes:
|
||||
# host path : container path
|
||||
- ./wg/data:/app/data
|
||||
- ./wg/config:/app/config
|
||||
```
|
||||
|
||||
- **Via a separate Docker container**:
|
||||
WireGuard Portal can interface with and control WireGuard running in another Docker container, such as the [linuxserver/wireguard](https://docs.linuxserver.io/images/docker-wireguard/) image.
|
||||
This method is useful in setups that already use `linuxserver/wireguard` or where you want to isolate the VPN backend from the portal frontend.
|
||||
For this, you need to set the network mode to `service:wireguard` in your docker-compose.yml file, `wireguard` is the service name of your WireGuard container.
|
||||
```yaml
|
||||
services:
|
||||
wg-portal:
|
||||
image: wgportal/wg-portal:latest
|
||||
container_name: wg-portal
|
||||
...
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
network_mode: "service:wireguard" # So we ensure to stay on the same network as the wireguard container.
|
||||
volumes:
|
||||
# host path : container path
|
||||
- ./wg/etc:/etc/wireguard
|
||||
- ./wg/data:/app/data
|
||||
- ./wg/config:/app/config
|
||||
|
||||
wireguard:
|
||||
image: lscr.io/linuxserver/wireguard:latest
|
||||
container_name: wireguard
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
ports:
|
||||
# host port : container port
|
||||
- "51820:51820/udp" # WireGuard port, needs to match the port in wg-portal interface config
|
||||
- "8888:8888/tcp" # Noticed that the port of the web UI is exposed in the wireguard container.
|
||||
volumes:
|
||||
- ./wg/etc:/config/wg_confs # We share the configuration (wgx.conf) between wg-portal and wireguard
|
||||
sysctls:
|
||||
- net.ipv4.conf.all.src_valid_mark=1
|
||||
```
|
||||
As the `linuxserver/wireguard` image uses _wg-quick_ to manage the interfaces, you need to have at least the following configuration set for WireGuard Portal:
|
||||
```yaml
|
||||
core:
|
||||
# The WireGuard container uses wg-quick to manage the WireGuard interfaces - this conflicts with WireGuard Portal during startup.
|
||||
# To avoid this, we need to set the restore_state option to false so that wg-quick can create the interfaces.
|
||||
restore_state: false
|
||||
# Usually, there are no existing interfaces in the WireGuard container, so we can set this to false.
|
||||
import_existing: false
|
||||
advanced:
|
||||
# WireGuard Portal needs to export the WireGuard configuration as wg-quick config files so that the WireGuard container can use them.
|
||||
config_storage_path: /etc/wireguard/
|
||||
```
|
||||
|
||||
## Image Versioning
|
||||
|
||||
All images are hosted on Docker Hub at [https://hub.docker.com/r/wgportal/wg-portal](https://hub.docker.com/r/wgportal/wg-portal).
|
||||
All images are hosted on Docker Hub at [https://hub.docker.com/r/wgportal/wg-portal](https://hub.docker.com/r/wgportal/wg-portal) or in the [GitHub Container Registry](https://github.com/h44z/wg-portal/pkgs/container/wg-portal).
|
||||
There are three types of tags in the repository:
|
||||
|
||||
#### Semantic versioned tags
|
||||
|
||||
For example, `1.0.19`.
|
||||
For example, `2.0.0-rc.1` or `v2.0.0-rc.1`.
|
||||
|
||||
These are official releases of WireGuard Portal. They correspond to the GitHub tags that we make, and you can see the release notes for them here: [https://github.com/h44z/wg-portal/releases](https://github.com/h44z/wg-portal/releases).
|
||||
|
||||
@@ -43,15 +136,22 @@ For each commit in the master and the stable branch, a corresponding Docker imag
|
||||
|
||||
## Configuration
|
||||
|
||||
You can configure WireGuard Portal using a yaml configuration file.
|
||||
The filepath of the yaml configuration file defaults to `/app/config/config.yml`.
|
||||
You can configure WireGuard Portal using a YAML configuration file.
|
||||
The filepath of the YAML configuration file defaults to `/app/config/config.yaml`.
|
||||
It is possible to override the configuration filepath using the environment variable **WG_PORTAL_CONFIG**.
|
||||
|
||||
By default, WireGuard Portal uses a SQLite database. The database is stored in `/app/data/sqlite.db`.
|
||||
By default, WireGuard Portal uses an SQLite database. The database is stored in `/app/data/sqlite.db`.
|
||||
|
||||
You should mount those directories as a volume:
|
||||
|
||||
- /app/data
|
||||
- /app/config
|
||||
- `/app/data`
|
||||
- `/app/config`
|
||||
|
||||
A detailed description of the configuration options can be found [here](../configuration/overview.md).
|
||||
|
||||
If you want to access configuration files in wg-quick format, you can mount the `/etc/wireguard` directory to a location of your choice.
|
||||
Also enable the `config_storage_path` option in the configuration file:
|
||||
```yaml
|
||||
advanced:
|
||||
config_storage_path: /etc/wireguard
|
||||
```
|
||||
|
98
docs/documentation/getting-started/reverse-proxy.md
Normal file
98
docs/documentation/getting-started/reverse-proxy.md
Normal file
@@ -0,0 +1,98 @@
|
||||
## Reverse Proxy for HTTPS
|
||||
|
||||
For production deployments, always serve the WireGuard Portal over HTTPS. You have two options to secure your connection:
|
||||
|
||||
|
||||
### Reverse Proxy
|
||||
|
||||
Let a front‐end proxy handle HTTPS for you. This also frees you from managing certificates manually and is therefore the preferred option.
|
||||
You can use Nginx, Traefik, Caddy or any other proxy.
|
||||
|
||||
Below is an example using a Docker Compose stack with [Traefik](https://traefik.io/traefik/).
|
||||
It exposes the WireGuard Portal on `https://wg.domain.com` and redirects initial HTTP traffic to HTTPS.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
reverse-proxy:
|
||||
image: traefik:v3.3
|
||||
restart: unless-stopped
|
||||
command:
|
||||
#- '--log.level=DEBUG'
|
||||
- '--providers.docker.endpoint=unix:///var/run/docker.sock'
|
||||
- '--providers.docker.exposedbydefault=false'
|
||||
- '--entrypoints.web.address=:80'
|
||||
- '--entrypoints.websecure.address=:443'
|
||||
- '--entrypoints.websecure.http3'
|
||||
- '--certificatesresolvers.letsencryptresolver.acme.httpchallenge=true'
|
||||
- '--certificatesresolvers.letsencryptresolver.acme.httpchallenge.entrypoint=web'
|
||||
- '--certificatesresolvers.letsencryptresolver.acme.email=your.email@domain.com'
|
||||
- '--certificatesresolvers.letsencryptresolver.acme.storage=/letsencrypt/acme.json'
|
||||
#- '--certificatesresolvers.letsencryptresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory' # just for testing
|
||||
ports:
|
||||
- 80:80 # for HTTP
|
||||
- 443:443/tcp # for HTTPS
|
||||
- 443:443/udp # for HTTP/3
|
||||
volumes:
|
||||
- acme-certs:/letsencrypt
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
# HTTP Catchall for redirecting HTTP -> HTTPS
|
||||
- 'traefik.http.routers.dashboard-catchall.rule=Host(`wg.domain.com`) && PathPrefix(`/`)'
|
||||
- 'traefik.http.routers.dashboard-catchall.entrypoints=web'
|
||||
- 'traefik.http.routers.dashboard-catchall.middlewares=redirect-to-https'
|
||||
- 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https'
|
||||
|
||||
wg-portal:
|
||||
image: wgportal/wg-portal:latest
|
||||
container_name: wg-portal
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
ports:
|
||||
# host port : container port
|
||||
# WireGuard port, needs to match the port in wg-portal interface config (add one port mapping for each interface)
|
||||
- "51820:51820/udp"
|
||||
# Web UI port (only available on localhost, Traefik will handle the HTTPS)
|
||||
- "127.0.0.1:8888:8888/tcp"
|
||||
sysctls:
|
||||
- net.ipv4.conf.all.src_valid_mark=1
|
||||
volumes:
|
||||
# host path : container path
|
||||
- ./wg/data:/app/data
|
||||
- ./wg/config:/app/config
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.wgportal.rule=Host(`wg.domain.com`)'
|
||||
- 'traefik.http.routers.wgportal.entrypoints=websecure'
|
||||
- 'traefik.http.routers.wgportal.tls.certresolver=letsencryptresolver'
|
||||
- 'traefik.http.routers.wgportal.service=wgportal'
|
||||
- 'traefik.http.services.wgportal.loadbalancer.server.port=8888'
|
||||
|
||||
volumes:
|
||||
acme-certs:
|
||||
```
|
||||
|
||||
The WireGuard Portal configuration must be updated accordingly so that the correct external URL is set for the web interface:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
external_url: https://wg.domain.com
|
||||
```
|
||||
|
||||
### Built-in TLS
|
||||
|
||||
If you prefer to let WireGuard Portal handle TLS itself, you can use the built-in TLS support.
|
||||
In your `config.yaml`, under the `web` section, point to your certificate and key files:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
cert_file: /path/to/your/fullchain.pem
|
||||
key_file: /path/to/your/privkey.pem
|
||||
```
|
||||
|
||||
The web server will then use these files to serve HTTPS traffic directly instead of HTTP.
|
@@ -22,3 +22,4 @@ make build
|
||||
## Install
|
||||
|
||||
Compiled binary will be available in `./dist` directory.
|
||||
For installation instructions, check the [Binaries](./binaries.md) section.
|
||||
|
@@ -1,12 +1,12 @@
|
||||
For production deployments of WireGuard Portal, we strongly recommend using version 1.
|
||||
If you want to use version 2, please be aware that it is still in beta and not feature complete.
|
||||
If you want to use version 2, please be aware that it is still a release candidate and not yet fully stable.
|
||||
|
||||
## Upgrade from v1 to v2
|
||||
|
||||
> :warning: Before upgrading from V1, make sure that you have a backup of your currently working configuration files and database!
|
||||
|
||||
To start the upgrade process, start the wg-portal binary with the **-migrateFrom** parameter.
|
||||
The configuration (config.yml) for WireGuard Portal must be updated and valid before starting the upgrade.
|
||||
The configuration (config.yaml) for WireGuard Portal must be updated and valid before starting the upgrade.
|
||||
|
||||
To upgrade from a previous SQLite database, start wg-portal like:
|
||||
|
||||
@@ -21,7 +21,7 @@ For example:
|
||||
./wg-portal-amd64 -migrateFromType=mysql -migrateFrom='user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local'
|
||||
```
|
||||
|
||||
The upgrade will transform the old, existing database and store the values in the new database specified in the **config.yml** configuration file.
|
||||
The upgrade will transform the old, existing database and store the values in the new database specified in the **config.yaml** configuration file.
|
||||
Ensure that the new database does not contain any data!
|
||||
|
||||
If you are using Docker, you can adapt the docker-compose.yml file to start the upgrade process:
|
||||
|
@@ -48,8 +48,11 @@ const languageFlag = computed(() => {
|
||||
}
|
||||
const langMap = {
|
||||
en: "us",
|
||||
pt: "pt",
|
||||
uk: "ua",
|
||||
zh: "cn",
|
||||
ko: "kr",
|
||||
|
||||
};
|
||||
return "fi-" + (langMap[lang] || lang);
|
||||
})
|
||||
@@ -82,6 +85,9 @@ const currentYear = ref(new Date().getFullYear())
|
||||
<li v-if="auth.IsAuthenticated && auth.IsAdmin" class="nav-item">
|
||||
<RouterLink :to="{ name: 'users' }" class="nav-link">{{ $t('menu.users') }}</RouterLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<RouterLink :to="{ name: 'key-generator' }" class="nav-link">{{ $t('menu.keygen') }}</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="navbar-nav d-flex justify-content-end">
|
||||
@@ -121,10 +127,13 @@ const currentYear = ref(new Date().getFullYear())
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('de')"><span class="fi fi-de"></span> Deutsch</a>
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('en')"><span class="fi fi-us"></span> English</a>
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('fr')"><span class="fi fi-fr"></span> Français</a>
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('ko')"><span class="fi fi-kr"></span> 한국어</a>
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('pt')"><span class="fi fi-pt"></span> Português</a>
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('ru')"><span class="fi fi-ru"></span> Русский</a>
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('uk')"><span class="fi fi-ua"></span> Українська</a>
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('vi')"><span class="fi fi-vi"></span> Tiếng Việt</a>
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('zh')"><span class="fi fi-cn"></span> 中文</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -331,11 +331,11 @@ async function del() {
|
||||
<legend class="mt-4">{{ $t('modals.interface-edit.header-crypto') }}</legend>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.private-key.label') }}</label>
|
||||
<input v-model="formData.PrivateKey" class="form-control" :placeholder="$t('modals.interface-edit.private-key.placeholder')" required type="email">
|
||||
<input v-model="formData.PrivateKey" class="form-control" :placeholder="$t('modals.interface-edit.private-key.placeholder')" required type="text">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.public-key.label') }}</label>
|
||||
<input v-model="formData.PublicKey" class="form-control" :placeholder="$t('modals.interface-edit.public-key.placeholder')" required type="email">
|
||||
<input v-model="formData.PublicKey" class="form-control" :placeholder="$t('modals.interface-edit.public-key.placeholder')" required type="text">
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
@@ -323,17 +323,18 @@ async function del() {
|
||||
<legend class="mt-4">{{ $t('modals.peer-edit.header-crypto') }}</legend>
|
||||
<div class="form-group" v-if="selectedInterface.Mode === 'server'">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.private-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
|
||||
v-model="formData.PrivateKey">
|
||||
<small id="privateKeyHelp" class="form-text text-muted">{{ $t('modals.peer-edit.private-key.help') }}</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.public-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
|
||||
v-model="formData.PublicKey">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.preshared-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
|
||||
v-model="formData.PresharedKey">
|
||||
</div>
|
||||
<div class="form-group" v-if="formData.Mode === 'client'">
|
||||
|
@@ -211,17 +211,18 @@ async function del() {
|
||||
<legend class="mt-4">{{ $t('modals.peer-edit.header-crypto') }}</legend>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.private-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
|
||||
v-model="formData.PrivateKey">
|
||||
<small id="privateKeyHelp" class="form-text text-muted">{{ $t('modals.peer-edit.private-key.help') }}</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.public-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
|
||||
v-model="formData.PublicKey">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.preshared-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
|
||||
v-model="formData.PresharedKey">
|
||||
</div>
|
||||
</fieldset>
|
||||
|
@@ -2,10 +2,13 @@
|
||||
import de from './translations/de.json';
|
||||
import en from './translations/en.json';
|
||||
import fr from './translations/fr.json';
|
||||
import ko from './translations/ko.json';
|
||||
import pt from './translations/pt.json';
|
||||
import ru from './translations/ru.json';
|
||||
import uk from './translations/uk.json';
|
||||
import vi from './translations/vi.json';
|
||||
import zh from './translations/zh.json';
|
||||
|
||||
import {createI18n} from "vue-i18n";
|
||||
|
||||
// Create i18n instance with options
|
||||
@@ -23,6 +26,8 @@ const i18n = createI18n({
|
||||
"de": de,
|
||||
"en": en,
|
||||
"fr": fr,
|
||||
"ko": ko,
|
||||
"pt": pt,
|
||||
"ru": ru,
|
||||
"uk": uk,
|
||||
"vi": vi,
|
||||
|
@@ -39,7 +39,8 @@
|
||||
"profile": "Mein Profil",
|
||||
"settings": "Einstellungen",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden"
|
||||
"logout": "Abmelden",
|
||||
"keygen": "Schlüsselgenerator"
|
||||
},
|
||||
"home": {
|
||||
"headline": "WireGuard® VPN Portal",
|
||||
@@ -188,6 +189,25 @@
|
||||
"api-link": "API Dokumentation"
|
||||
}
|
||||
},
|
||||
"keygen": {
|
||||
"headline": "WireGuard Key Generator",
|
||||
"abstract": "Hier können Sie WireGuard Schlüsselpaare generieren. Die Schlüssel werden lokal auf Ihrem Computer generiert und niemals an den Server gesendet.",
|
||||
"headline-keypair": "Neues Schlüsselpaar",
|
||||
"headline-preshared-key": "Neuer Pre-shared Key",
|
||||
"button-generate": "Erzeugen",
|
||||
"private-key": {
|
||||
"label": "Private Key",
|
||||
"placeholder": "Der private Schlüssel"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Public Key",
|
||||
"placeholder": "Der öffentliche Schlüssel"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "Preshared Key",
|
||||
"placeholder": "Der Pre-shared Schlüssel"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
"user-view": {
|
||||
"headline": "User Account:",
|
||||
@@ -420,7 +440,8 @@
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Private Key",
|
||||
"placeholder": "The private key"
|
||||
"placeholder": "The private key",
|
||||
"help": "Der private Schlüssel wird sicher auf dem Server gespeichert. Wenn der Benutzer bereits eine Kopie besitzt, kann dieses Feld entfallen. Der Server funktioniert auch ausschließlich mit dem öffentlichen Schlüssel des Peers."
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Public Key",
|
||||
|
@@ -40,7 +40,8 @@
|
||||
"settings": "Settings",
|
||||
"audit": "Audit Log",
|
||||
"login": "Login",
|
||||
"logout": "Logout"
|
||||
"logout": "Logout",
|
||||
"keygen": "Key Generator"
|
||||
},
|
||||
"home": {
|
||||
"headline": "WireGuard® VPN Portal",
|
||||
@@ -206,6 +207,25 @@
|
||||
"message": "Message"
|
||||
}
|
||||
},
|
||||
"keygen": {
|
||||
"headline": "WireGuard Key Generator",
|
||||
"abstract": "Generate a new WireGuard keys. The keys are generated in your local browser and are never sent to the server.",
|
||||
"headline-keypair": "New Key Pair",
|
||||
"headline-preshared-key": "New Preshared Key",
|
||||
"button-generate": "Generate",
|
||||
"private-key": {
|
||||
"label": "Private Key",
|
||||
"placeholder": "The private key"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Public Key",
|
||||
"placeholder": "The public key"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "Preshared Key",
|
||||
"placeholder": "The pre-shared key"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
"user-view": {
|
||||
"headline": "User Account:",
|
||||
@@ -439,7 +459,8 @@
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Private Key",
|
||||
"placeholder": "The private key"
|
||||
"placeholder": "The private key",
|
||||
"help": "The private key is stored securely on the server. If the user already holds a copy, you may omit this field. The server still functions exclusively with the peer’s public key."
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Public Key",
|
||||
|
532
frontend/src/lang/translations/ko.json
Normal file
532
frontend/src/lang/translations/ko.json
Normal file
@@ -0,0 +1,532 @@
|
||||
{
|
||||
"languages": {
|
||||
"ko": "한국어"
|
||||
},
|
||||
"general": {
|
||||
"pagination": {
|
||||
"size": "항목 수",
|
||||
"all": "전체 (느림)"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "검색...",
|
||||
"button": "검색"
|
||||
},
|
||||
"select-all": "모두 선택",
|
||||
"yes": "예",
|
||||
"no": "아니오",
|
||||
"cancel": "취소",
|
||||
"close": "닫기",
|
||||
"save": "저장",
|
||||
"delete": "삭제"
|
||||
},
|
||||
"login": {
|
||||
"headline": "로그인하세요",
|
||||
"username": {
|
||||
"label": "사용자 이름",
|
||||
"placeholder": "사용자 이름을 입력하세요"
|
||||
},
|
||||
"password": {
|
||||
"label": "비밀번호",
|
||||
"placeholder": "비밀번호를 입력하세요"
|
||||
},
|
||||
"button": "로그인"
|
||||
},
|
||||
"menu": {
|
||||
"home": "홈",
|
||||
"interfaces": "인터페이스",
|
||||
"users": "사용자",
|
||||
"lang": "언어 변경",
|
||||
"profile": "내 프로필",
|
||||
"settings": "설정",
|
||||
"audit": "감사 로그",
|
||||
"login": "로그인",
|
||||
"logout": "로그아웃"
|
||||
},
|
||||
"home": {
|
||||
"headline": "WireGuard® VPN 포털",
|
||||
"info-headline": "추가 정보",
|
||||
"abstract": "WireGuard®는 암호화 기술을 활용하는 매우 간단하면서도 빠르고 현대적인 VPN입니다. IPsec보다 빠르고, 간단하며, 가볍고, 더 유용하면서도 엄청난 골칫거리를 피하는 것을 목표로 합니다. OpenVPN보다 훨씬 더 성능이 뛰어날 것으로 예상됩니다.",
|
||||
"installation": {
|
||||
"box-header": "WireGuard 설치",
|
||||
"headline": "설치",
|
||||
"content": "클라이언트 소프트웨어 설치 지침은 공식 WireGuard 웹사이트에서 찾을 수 있습니다.",
|
||||
"button": "지침 열기"
|
||||
},
|
||||
"about-wg": {
|
||||
"box-header": "WireGuard 정보",
|
||||
"headline": "정보",
|
||||
"content": "WireGuard®는 암호화 기술을 활용하는 매우 간단하면서도 빠르고 현대적인 VPN입니다.",
|
||||
"button": "더 보기"
|
||||
},
|
||||
"about-portal": {
|
||||
"box-header": "WireGuard 포털 정보",
|
||||
"headline": "WireGuard 포털",
|
||||
"content": "WireGuard 포털은 WireGuard를 위한 간단한 웹 기반 구성 포털입니다.",
|
||||
"button": "더 보기"
|
||||
},
|
||||
"profiles": {
|
||||
"headline": "VPN 프로필",
|
||||
"abstract": "사용자 프로필을 통해 개인 VPN 구성에 액세스하고 다운로드할 수 있습니다.",
|
||||
"content": "구성된 모든 프로필을 찾으려면 아래 버튼을 클릭하세요.",
|
||||
"button": "내 프로필 열기"
|
||||
},
|
||||
"admin": {
|
||||
"headline": "관리 영역",
|
||||
"abstract": "관리 영역에서는 WireGuard 피어 및 서버 인터페이스뿐만 아니라 WireGuard 포털에 로그인할 수 있는 사용자도 관리할 수 있습니다.",
|
||||
"content": "",
|
||||
"button-admin": "서버 관리 열기",
|
||||
"button-user": "사용자 관리 열기"
|
||||
}
|
||||
},
|
||||
"interfaces": {
|
||||
"headline": "인터페이스 관리",
|
||||
"headline-peers": "현재 VPN 피어",
|
||||
"headline-endpoints": "현재 엔드포인트",
|
||||
"no-interface": {
|
||||
"default-selection": "사용 가능한 인터페이스 없음",
|
||||
"headline": "인터페이스를 찾을 수 없습니다...",
|
||||
"abstract": "새 WireGuard 인터페이스를 만들려면 위의 플러스 버튼을 클릭하세요."
|
||||
},
|
||||
"no-peer": {
|
||||
"headline": "사용 가능한 피어 없음",
|
||||
"abstract": "현재 선택한 WireGuard 인터페이스에 사용 가능한 피어가 없습니다."
|
||||
},
|
||||
"table-heading": {
|
||||
"name": "이름",
|
||||
"user": "사용자",
|
||||
"ip": "IP 주소",
|
||||
"endpoint": "엔드포인트",
|
||||
"status": "상태"
|
||||
},
|
||||
"interface": {
|
||||
"headline": "인터페이스 상태:",
|
||||
"mode": "모드",
|
||||
"key": "공개 키",
|
||||
"endpoint": "공개 엔드포인트",
|
||||
"port": "수신 포트",
|
||||
"peers": "활성화된 피어",
|
||||
"total-peers": "총 피어 수",
|
||||
"endpoints": "활성화된 엔드포인트",
|
||||
"total-endpoints": "총 엔드포인트 수",
|
||||
"ip": "IP 주소",
|
||||
"default-allowed-ip": "기본 허용 IP",
|
||||
"dns": "DNS 서버",
|
||||
"mtu": "MTU",
|
||||
"default-keep-alive": "기본 Keepalive 간격",
|
||||
"button-show-config": "구성 보기",
|
||||
"button-download-config": "구성 다운로드",
|
||||
"button-store-config": "wg-quick용 구성 저장",
|
||||
"button-edit": "인터페이스 편집"
|
||||
},
|
||||
"button-add-interface": "인터페이스 추가",
|
||||
"button-add-peer": "피어 추가",
|
||||
"button-add-peers": "여러 피어 추가",
|
||||
"button-show-peer": "피어 보기",
|
||||
"button-edit-peer": "피어 편집",
|
||||
"peer-disabled": "피어가 비활성화됨, 이유:",
|
||||
"peer-expiring": "피어 만료 예정:",
|
||||
"peer-connected": "연결됨",
|
||||
"peer-not-connected": "연결되지 않음",
|
||||
"peer-handshake": "마지막 핸드셰이크:"
|
||||
},
|
||||
"users": {
|
||||
"headline": "사용자 관리",
|
||||
"table-heading": {
|
||||
"id": "ID",
|
||||
"email": "이메일",
|
||||
"firstname": "이름",
|
||||
"lastname": "성",
|
||||
"source": "소스",
|
||||
"peers": "피어",
|
||||
"admin": "관리자"
|
||||
},
|
||||
"no-user": {
|
||||
"headline": "사용 가능한 사용자 없음",
|
||||
"abstract": "현재 WireGuard 포털에 등록된 사용자가 없습니다."
|
||||
},
|
||||
"button-add-user": "사용자 추가",
|
||||
"button-show-user": "사용자 보기",
|
||||
"button-edit-user": "사용자 편집",
|
||||
"user-disabled": "사용자가 비활성화됨, 이유:",
|
||||
"user-locked": "계정이 잠김, 이유:",
|
||||
"admin": "사용자에게 관리자 권한이 있습니다",
|
||||
"no-admin": "사용자에게 관리자 권한이 없습니다"
|
||||
},
|
||||
"profile": {
|
||||
"headline": "내 VPN 피어",
|
||||
"table-heading": {
|
||||
"name": "이름",
|
||||
"ip": "IP 주소",
|
||||
"stats": "상태",
|
||||
"interface": "서버 인터페이스"
|
||||
},
|
||||
"no-peer": {
|
||||
"headline": "사용 가능한 피어 없음",
|
||||
"abstract": "현재 사용자 프로필과 연결된 피어가 없습니다."
|
||||
},
|
||||
"peer-connected": "연결됨",
|
||||
"button-add-peer": "피어 추가",
|
||||
"button-show-peer": "피어 보기",
|
||||
"button-edit-peer": "피어 편집"
|
||||
},
|
||||
"settings": {
|
||||
"headline": "설정",
|
||||
"abstract": "여기에서 개인 설정을 변경할 수 있습니다.",
|
||||
"api": {
|
||||
"headline": "API 설정",
|
||||
"abstract": "여기에서 RESTful API 설정을 구성할 수 있습니다.",
|
||||
"active-description": "현재 사용자 계정에 대해 API가 활성화되어 있습니다. 모든 API 요청은 기본 인증(Basic Auth)으로 인증됩니다. 인증에 다음 자격 증명을 사용하세요.",
|
||||
"inactive-description": "현재 API가 비활성화되어 있습니다. 활성화하려면 아래 버튼을 누르세요.",
|
||||
"user-label": "API 사용자 이름:",
|
||||
"user-placeholder": "API 사용자",
|
||||
"token-label": "API 비밀번호:",
|
||||
"token-placeholder": "API 토큰",
|
||||
"token-created-label": "API 액세스 권한 부여 시각: ",
|
||||
"button-disable-title": "API를 비활성화합니다. 현재 토큰이 무효화됩니다.",
|
||||
"button-disable-text": "API 비활성화",
|
||||
"button-enable-title": "API를 활성화합니다. 새 토큰이 생성됩니다.",
|
||||
"button-enable-text": "API 활성화",
|
||||
"api-link": "API 문서"
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"headline": "감사 로그",
|
||||
"abstract": "여기에서 WireGuard 포털에서 수행된 모든 작업의 감사 로그를 찾을 수 있습니다.",
|
||||
"no-entries": {
|
||||
"headline": "로그 항목 없음",
|
||||
"abstract": "현재 기록된 감사 로그가 없습니다."
|
||||
},
|
||||
"entries-headline": "로그 항목",
|
||||
"table-heading": {
|
||||
"id": "#",
|
||||
"time": "시간",
|
||||
"user": "사용자",
|
||||
"severity": "심각도",
|
||||
"origin": "출처",
|
||||
"message": "메시지"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
"user-view": {
|
||||
"headline": "사용자 계정:",
|
||||
"tab-user": "정보",
|
||||
"tab-peers": "피어",
|
||||
"headline-info": "사용자 정보:",
|
||||
"headline-notes": "메모:",
|
||||
"email": "이메일",
|
||||
"firstname": "이름",
|
||||
"lastname": "성",
|
||||
"phone": "전화번호",
|
||||
"department": "부서",
|
||||
"api-enabled": "API 액세스",
|
||||
"disabled": "계정 비활성화됨",
|
||||
"locked": "계정 잠김",
|
||||
"no-peers": "사용자에게 연결된 피어가 없습니다.",
|
||||
"peers": {
|
||||
"name": "이름",
|
||||
"interface": "인터페이스",
|
||||
"ip": "IP 주소"
|
||||
}
|
||||
},
|
||||
"user-edit": {
|
||||
"headline-edit": "사용자 편집:",
|
||||
"headline-new": "새 사용자",
|
||||
"header-general": "일반",
|
||||
"header-personal": "사용자 정보",
|
||||
"header-notes": "메모",
|
||||
"header-state": "상태",
|
||||
"identifier": {
|
||||
"label": "식별자",
|
||||
"placeholder": "고유한 사용자 식별자"
|
||||
},
|
||||
"source": {
|
||||
"label": "소스",
|
||||
"placeholder": "사용자 소스"
|
||||
},
|
||||
"password": {
|
||||
"label": "비밀번호",
|
||||
"placeholder": "매우 비밀스러운 비밀번호",
|
||||
"description": "현재 비밀번호를 유지하려면 이 필드를 비워 두세요."
|
||||
},
|
||||
"email": {
|
||||
"label": "이메일",
|
||||
"placeholder": "이메일 주소"
|
||||
},
|
||||
"phone": {
|
||||
"label": "전화번호",
|
||||
"placeholder": "전화번호"
|
||||
},
|
||||
"department": {
|
||||
"label": "부서",
|
||||
"placeholder": "부서"
|
||||
},
|
||||
"firstname": {
|
||||
"label": "이름",
|
||||
"placeholder": "이름"
|
||||
},
|
||||
"lastname": {
|
||||
"label": "성",
|
||||
"placeholder": "성"
|
||||
},
|
||||
"notes": {
|
||||
"label": "메모",
|
||||
"placeholder": ""
|
||||
},
|
||||
"disabled": {
|
||||
"label": "비활성화됨 (WireGuard 연결 및 로그인 불가)"
|
||||
},
|
||||
"locked": {
|
||||
"label": "잠김 (로그인 불가, WireGuard 연결은 계속 작동)"
|
||||
},
|
||||
"admin": {
|
||||
"label": "관리자 여부"
|
||||
}
|
||||
},
|
||||
"interface-view": {
|
||||
"headline": "인터페이스 구성:"
|
||||
},
|
||||
"interface-edit": {
|
||||
"headline-edit": "인터페이스 편집:",
|
||||
"headline-new": "새 인터페이스",
|
||||
"tab-interface": "인터페이스",
|
||||
"tab-peerdef": "피어 기본값",
|
||||
"header-general": "일반",
|
||||
"header-network": "네트워크",
|
||||
"header-crypto": "암호화",
|
||||
"header-hooks": "인터페이스 후크",
|
||||
"header-peer-hooks": "후크",
|
||||
"header-state": "상태",
|
||||
"identifier": {
|
||||
"label": "식별자",
|
||||
"placeholder": "고유한 인터페이스 식별자"
|
||||
},
|
||||
"mode": {
|
||||
"label": "인터페이스 모드",
|
||||
"server": "서버 모드",
|
||||
"client": "클라이언트 모드",
|
||||
"any": "알 수 없는 모드"
|
||||
},
|
||||
"display-name": {
|
||||
"label": "표시 이름",
|
||||
"placeholder": "인터페이스에 대한 설명적인 이름"
|
||||
},
|
||||
"private-key": {
|
||||
"label": "개인 키",
|
||||
"placeholder": "개인 키"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "공개 키",
|
||||
"placeholder": "공개 키"
|
||||
},
|
||||
"ip": {
|
||||
"label": "IP 주소",
|
||||
"placeholder": "IP 주소 (CIDR 형식)"
|
||||
},
|
||||
"listen-port": {
|
||||
"label": "수신 포트",
|
||||
"placeholder": "수신 포트"
|
||||
},
|
||||
"dns": {
|
||||
"label": "DNS 서버",
|
||||
"placeholder": "사용해야 하는 DNS 서버"
|
||||
},
|
||||
"dns-search": {
|
||||
"label": "DNS 검색 도메인",
|
||||
"placeholder": "DNS 검색 접두사"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "인터페이스 MTU (0 = 기본값 유지)"
|
||||
},
|
||||
"firewall-mark": {
|
||||
"label": "방화벽 표시",
|
||||
"placeholder": "나가는 트래픽에 적용되는 방화벽 표시. (0 = 자동)"
|
||||
},
|
||||
"routing-table": {
|
||||
"label": "라우팅 테이블",
|
||||
"placeholder": "라우팅 테이블 ID",
|
||||
"description": "특수 사례: off = 경로 관리 안 함, 0 = 자동"
|
||||
},
|
||||
"pre-up": {
|
||||
"label": "Pre-Up",
|
||||
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
|
||||
},
|
||||
"post-up": {
|
||||
"label": "Post-Up",
|
||||
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
|
||||
},
|
||||
"pre-down": {
|
||||
"label": "Pre-Down",
|
||||
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
|
||||
},
|
||||
"post-down": {
|
||||
"label": "Post-Down",
|
||||
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
|
||||
},
|
||||
"disabled": {
|
||||
"label": "인터페이스 비활성화됨"
|
||||
},
|
||||
"save-config": {
|
||||
"label": "wg-quick 구성 자동 저장"
|
||||
},
|
||||
"defaults": {
|
||||
"endpoint": {
|
||||
"label": "엔드포인트 주소",
|
||||
"placeholder": "엔드포인트 주소",
|
||||
"description": "피어가 연결할 엔드포인트 주소. (예: wg.example.com 또는 wg.example.com:51820)"
|
||||
},
|
||||
"networks": {
|
||||
"label": "IP 네트워크",
|
||||
"placeholder": "네트워크 주소",
|
||||
"description": "피어는 해당 서브넷에서 IP 주소를 받습니다."
|
||||
},
|
||||
"allowed-ip": {
|
||||
"label": "허용된 IP 주소",
|
||||
"placeholder": "기본 허용 IP 주소"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "클라이언트 MTU (0 = 기본값 유지)"
|
||||
},
|
||||
"keep-alive": {
|
||||
"label": "Keep Alive 간격",
|
||||
"placeholder": "영구 Keepalive (0 = 기본값)"
|
||||
}
|
||||
},
|
||||
"button-apply-defaults": "피어 기본값 적용"
|
||||
},
|
||||
"peer-view": {
|
||||
"headline-peer": "피어:",
|
||||
"headline-endpoint": "엔드포인트:",
|
||||
"section-info": "피어 정보",
|
||||
"section-status": "현재 상태",
|
||||
"section-config": "구성",
|
||||
"identifier": "식별자",
|
||||
"ip": "IP 주소",
|
||||
"user": "연결된 사용자",
|
||||
"notes": "메모",
|
||||
"expiry-status": "만료 시각",
|
||||
"disabled-status": "비활성화 시각",
|
||||
"traffic": "트래픽",
|
||||
"connection-status": "연결 통계",
|
||||
"upload": "업로드된 바이트 (서버에서 피어로)",
|
||||
"download": "다운로드된 바이트 (피어에서 서버로)",
|
||||
"pingable": "핑 가능 여부",
|
||||
"handshake": "마지막 핸드셰이크",
|
||||
"connected-since": "연결 시작 시각",
|
||||
"endpoint": "엔드포인트",
|
||||
"button-download": "구성 다운로드",
|
||||
"button-email": "이메일로 구성 보내기"
|
||||
},
|
||||
"peer-edit": {
|
||||
"headline-edit-peer": "피어 편집:",
|
||||
"headline-edit-endpoint": "엔드포인트 편집:",
|
||||
"headline-new-peer": "피어 생성",
|
||||
"headline-new-endpoint": "엔드포인트 생성",
|
||||
"header-general": "일반",
|
||||
"header-network": "네트워크",
|
||||
"header-crypto": "암호화",
|
||||
"header-hooks": "후크 (피어에서 실행됨)",
|
||||
"header-state": "상태",
|
||||
"display-name": {
|
||||
"label": "표시 이름",
|
||||
"placeholder": "피어에 대한 설명적인 이름"
|
||||
},
|
||||
"linked-user": {
|
||||
"label": "연결된 사용자",
|
||||
"placeholder": "이 피어를 소유한 사용자 계정"
|
||||
},
|
||||
"private-key": {
|
||||
"label": "개인 키",
|
||||
"placeholder": "개인 키"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "공개 키",
|
||||
"placeholder": "공개 키"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "사전 공유 키",
|
||||
"placeholder": "선택적 사전 공유 키"
|
||||
},
|
||||
"endpoint-public-key": {
|
||||
"label": "엔드포인트 공개 키",
|
||||
"placeholder": "원격 엔드포인트의 공개 키"
|
||||
},
|
||||
"endpoint": {
|
||||
"label": "엔드포인트 주소",
|
||||
"placeholder": "원격 엔드포인트의 주소"
|
||||
},
|
||||
"ip": {
|
||||
"label": "IP 주소",
|
||||
"placeholder": "IP 주소 (CIDR 형식)"
|
||||
},
|
||||
"allowed-ip": {
|
||||
"label": "허용된 IP 주소",
|
||||
"placeholder": "허용된 IP 주소 (CIDR 형식)"
|
||||
},
|
||||
"extra-allowed-ip": {
|
||||
"label": "추가 허용 IP 주소",
|
||||
"placeholder": "추가 허용 IP (서버 측)",
|
||||
"description": "이 IP 주소는 원격 WireGuard 인터페이스에 허용된 IP로 추가됩니다."
|
||||
},
|
||||
"dns": {
|
||||
"label": "DNS 서버",
|
||||
"placeholder": "사용해야 하는 DNS 서버"
|
||||
},
|
||||
"dns-search": {
|
||||
"label": "DNS 검색 도메인",
|
||||
"placeholder": "DNS 검색 접두사"
|
||||
},
|
||||
"keep-alive": {
|
||||
"label": "Keep Alive 간격",
|
||||
"placeholder": "영구 Keepalive (0 = 기본값)"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "클라이언트 MTU (0 = 기본값 유지)"
|
||||
},
|
||||
"pre-up": {
|
||||
"label": "Pre-Up",
|
||||
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
|
||||
},
|
||||
"post-up": {
|
||||
"label": "Post-Up",
|
||||
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
|
||||
},
|
||||
"pre-down": {
|
||||
"label": "Pre-Down",
|
||||
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
|
||||
},
|
||||
"post-down": {
|
||||
"label": "Post-Down",
|
||||
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
|
||||
},
|
||||
"disabled": {
|
||||
"label": "피어 비활성화됨"
|
||||
},
|
||||
"ignore-global": {
|
||||
"label": "전역 설정 무시"
|
||||
},
|
||||
"expires-at": {
|
||||
"label": "만료 날짜"
|
||||
}
|
||||
},
|
||||
"peer-multi-create": {
|
||||
"headline-peer": "여러 피어 생성",
|
||||
"headline-endpoint": "여러 엔드포인트 생성",
|
||||
"identifiers": {
|
||||
"label": "사용자 식별자",
|
||||
"placeholder": "사용자 식별자",
|
||||
"description": "피어를 생성할 사용자 식별자 (사용자 이름)."
|
||||
},
|
||||
"prefix": {
|
||||
"headline-peer": "피어:",
|
||||
"headline-endpoint": "엔드포인트:",
|
||||
"label": "표시 이름 접두사",
|
||||
"placeholder": "접두사",
|
||||
"description": "피어 표시 이름에 추가되는 접두사."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
182
frontend/src/lang/translations/pt.json
Normal file
182
frontend/src/lang/translations/pt.json
Normal file
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"languages": {
|
||||
"pt": "Português"
|
||||
},
|
||||
"general": {
|
||||
"pagination": {
|
||||
"size": "Número de Elementos",
|
||||
"all": "Todos (lento)"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Pesquisar...",
|
||||
"button": "Pesquisar"
|
||||
},
|
||||
"select-all": "Selecionar tudo",
|
||||
"yes": "Sim",
|
||||
"no": "Não",
|
||||
"cancel": "Cancelar",
|
||||
"close": "Fechar",
|
||||
"save": "Guardar",
|
||||
"delete": "Eliminar"
|
||||
},
|
||||
"login": {
|
||||
"headline": "Por favor, inicie sessão",
|
||||
"username": {
|
||||
"label": "Nome de utilizador",
|
||||
"placeholder": "Introduza o seu nome de utilizador"
|
||||
},
|
||||
"password": {
|
||||
"label": "Palavra-passe",
|
||||
"placeholder": "Introduza a sua palavra-passe"
|
||||
},
|
||||
"button": "Iniciar sessão"
|
||||
},
|
||||
"menu": {
|
||||
"home": "Início",
|
||||
"interfaces": "Interfaces",
|
||||
"users": "Utilizadores",
|
||||
"lang": "Alterar idioma",
|
||||
"profile": "O Meu Perfil",
|
||||
"settings": "Definições",
|
||||
"audit": "Registo de Auditoria",
|
||||
"login": "Iniciar Sessão",
|
||||
"logout": "Terminar Sessão"
|
||||
},
|
||||
"home": {
|
||||
"title": "Início",
|
||||
"card": {
|
||||
"interfaces": "Interfaces",
|
||||
"users": "Utilizadores"
|
||||
}
|
||||
},
|
||||
"interfaces": {
|
||||
"title": "Interfaces",
|
||||
"create": "Criar Interface",
|
||||
"name": "Nome",
|
||||
"address": "Endereço",
|
||||
"listen-port": "Porta de Escuta",
|
||||
"public-key": "Chave Pública",
|
||||
"private-key": "Chave Privada",
|
||||
"actions": "Ações",
|
||||
"delete-dialog": {
|
||||
"title": "Eliminar Interface",
|
||||
"text": "Tem a certeza de que deseja eliminar a interface '{{name}}'?"
|
||||
},
|
||||
"form": {
|
||||
"name": {
|
||||
"label": "Nome",
|
||||
"placeholder": "Introduza um nome exclusivo para a interface"
|
||||
},
|
||||
"address": {
|
||||
"label": "Endereço",
|
||||
"placeholder": "Introduza um endereço válido (ex: 10.0.0.1/24)"
|
||||
},
|
||||
"listen-port": {
|
||||
"label": "Porta de Escuta",
|
||||
"placeholder": "Introduza a porta onde o WireGuard irá escutar (ex: 51820)"
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Chave Privada",
|
||||
"placeholder": "Será gerada automaticamente se não for fornecida"
|
||||
}
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"title": "Utilizadores",
|
||||
"create": "Criar Utilizador",
|
||||
"name": "Nome",
|
||||
"email": "Email",
|
||||
"enabled": "Ativo",
|
||||
"is-admin": "Administrador",
|
||||
"actions": "Ações",
|
||||
"edit": "Editar",
|
||||
"delete-dialog": {
|
||||
"title": "Eliminar Utilizador",
|
||||
"text": "Tem a certeza de que deseja eliminar o utilizador '{{nome}}'?"
|
||||
},
|
||||
"form": {
|
||||
"name": {
|
||||
"label": "Nome",
|
||||
"placeholder": "Introduza o nome do utilizador"
|
||||
},
|
||||
"email": {
|
||||
"label": "Email",
|
||||
"placeholder": "Introduza o email do utilizador"
|
||||
},
|
||||
"password": {
|
||||
"label": "Palavra-passe",
|
||||
"placeholder": "Deixe em branco para manter a atual"
|
||||
},
|
||||
"is-admin": {
|
||||
"label": "Administrador"
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Ativo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"peers": {
|
||||
"title": "Peers",
|
||||
"create": "Criar Peer",
|
||||
"public-key": "Chave Pública",
|
||||
"preshared-key": "Chave Pré-partilhada",
|
||||
"endpoint": "Endpoint",
|
||||
"allowed-ips": "IPs Permitidos",
|
||||
"latest-handshake": "Último Handshake",
|
||||
"transfer-rx": "Recebido",
|
||||
"transfer-tx": "Enviado",
|
||||
"persistent-keepalive": "Keepalive Persistente",
|
||||
"actions": "Ações",
|
||||
"edit": "Editar",
|
||||
"delete-dialog": {
|
||||
"title": "Eliminar Peer",
|
||||
"text": "Tem a certeza de que deseja eliminar este peer?"
|
||||
},
|
||||
"form": {
|
||||
"public-key": {
|
||||
"label": "Chave Pública",
|
||||
"placeholder": "Introduza a chave pública do peer"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "Chave Pré-partilhada",
|
||||
"placeholder": "Opcional: Chave partilhada adicional para maior segurança"
|
||||
},
|
||||
"endpoint": {
|
||||
"label": "Endpoint",
|
||||
"placeholder": "Endereço público do peer (ex: 1.2.3.4:51820)"
|
||||
},
|
||||
"allowed-ips": {
|
||||
"label": "IPs Permitidos",
|
||||
"placeholder": "Lista de IPs (ex: 10.0.0.2/32, 192.168.1.0/24)"
|
||||
},
|
||||
"persistent-keepalive": {
|
||||
"label": "Keepalive Persistente",
|
||||
"placeholder": "Ex: 25 (em segundos)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Definições",
|
||||
"password": {
|
||||
"label": "Nova Palavra-passe",
|
||||
"placeholder": "Deixe em branco para manter a atual"
|
||||
},
|
||||
"save": "Guardar Alterações"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Registo de Auditoria",
|
||||
"username": "Utilizador",
|
||||
"ip": "Endereço IP",
|
||||
"method": "Método",
|
||||
"path": "Caminho",
|
||||
"status": "Estado",
|
||||
"timestamp": "Data/Hora"
|
||||
},
|
||||
"errors": {
|
||||
"required": "Este campo é obrigatório",
|
||||
"invalid-email": "Endereço de email inválido",
|
||||
"invalid-address": "Endereço inválido",
|
||||
"invalid-endpoint": "Endpoint inválido",
|
||||
"invalid-allowed-ips": "Formato de IPs Permitidos inválido"
|
||||
}
|
||||
}
|
@@ -64,6 +64,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/AuditView.vue')
|
||||
},
|
||||
{
|
||||
path: '/key-generator',
|
||||
name: 'key-generator',
|
||||
// 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/KeyGeneraterView.vue')
|
||||
}
|
||||
],
|
||||
linkActiveClass: "active",
|
||||
@@ -114,11 +122,11 @@ router.beforeEach(async (to) => {
|
||||
}
|
||||
|
||||
// redirect to login page if not logged in and trying to access a restricted page
|
||||
const publicPages = ['/', '/login']
|
||||
const publicPages = ['/', '/login', '/key-generator']
|
||||
const authRequired = !publicPages.includes(to.path)
|
||||
|
||||
if (authRequired && !auth.IsAuthenticated) {
|
||||
auth.SetReturnUrl(to.fullPath) // store original destination before starting the auth process
|
||||
auth.SetReturnUrl(to.fullPath) // store the original destination before starting the auth process
|
||||
return '/login'
|
||||
}
|
||||
})
|
||||
|
147
frontend/src/views/KeyGeneraterView.vue
Normal file
147
frontend/src/views/KeyGeneraterView.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup>
|
||||
|
||||
import {ref} from "vue";
|
||||
|
||||
const privateKey = ref("")
|
||||
const publicKey = ref("")
|
||||
const presharedKey = ref("")
|
||||
|
||||
/**
|
||||
* Generate an X25519 keypair using the Web Crypto API and return Base64-encoded strings.
|
||||
* @async
|
||||
* @function generateKeypair
|
||||
* @returns {Promise<{ publicKey: string, privateKey: string }>} Resolves with an object containing
|
||||
* - publicKey: the Base64-encoded public key
|
||||
* - privateKey: the Base64-encoded private key
|
||||
*/
|
||||
async function generateKeypair() {
|
||||
// 1. Generate an X25519 key pair
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'X25519', namedCurve: 'X25519' },
|
||||
true, // extractable
|
||||
['deriveBits'] // allowed usage for ECDH
|
||||
);
|
||||
|
||||
// 2. Export keys as JWK to access raw key material
|
||||
const pubJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
||||
const privJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
||||
|
||||
// 3. Convert Base64URL to standard Base64 with padding
|
||||
return {
|
||||
publicKey: b64urlToB64(pubJwk.x),
|
||||
privateKey: b64urlToB64(privJwk.d)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 32-byte pre-shared key using crypto.getRandomValues.
|
||||
* @function generatePresharedKey
|
||||
* @returns {Uint8Array} A Uint8Array of length 32 with random bytes.
|
||||
*/
|
||||
function generatePresharedKey() {
|
||||
let privateKey = new Uint8Array(32);
|
||||
window.crypto.getRandomValues(privateKey);
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Base64URL-encoded string to standard Base64 with padding.
|
||||
* @function b64urlToB64
|
||||
* @param {string} input - The Base64URL string.
|
||||
* @returns {string} The padded, standard Base64 string.
|
||||
*/
|
||||
function b64urlToB64(input) {
|
||||
let b64 = input.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (b64.length % 4) {
|
||||
b64 += '=';
|
||||
}
|
||||
return b64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an ArrayBuffer or TypedArray buffer to a Base64-encoded string.
|
||||
* @function arrayBufferToBase64
|
||||
* @param {ArrayBuffer|Uint8Array} buffer - The buffer to convert.
|
||||
* @returns {string} Base64-encoded representation of the buffer.
|
||||
*/
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; ++i) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
// Window.btoa handles binary → Base64
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new keypair and update the corresponding Vue refs.
|
||||
* @async
|
||||
* @function generateNewKeyPair
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function generateNewKeyPair() {
|
||||
const keypair = await generateKeypair();
|
||||
|
||||
privateKey.value = keypair.privateKey;
|
||||
publicKey.value = keypair.publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new pre-shared key and update the Vue ref.
|
||||
* @function generateNewPresharedKey
|
||||
*/
|
||||
function generateNewPresharedKey() {
|
||||
const rawPsk = generatePresharedKey();
|
||||
presharedKey.value = arrayBufferToBase64(rawPsk);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>{{ $t('keygen.headline') }}</h1>
|
||||
</div>
|
||||
|
||||
<p class="lead">{{ $t('keygen.abstract') }}</p>
|
||||
|
||||
<div class="mt-4 row">
|
||||
<div class="col-12 col-lg-5">
|
||||
<h1>{{ $t('keygen.headline-keypair') }}</h1>
|
||||
<fieldset>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('keygen.private-key.label') }}</label>
|
||||
<input class="form-control" v-model="privateKey" :placeholder="$t('keygen.private-key.placeholder')" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('keygen.public-key.label') }}</label>
|
||||
<input class="form-control" v-model="publicKey" :placeholder="$t('keygen.private-key.placeholder')" readonly>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<hr class="mt-4">
|
||||
<button class="btn btn-primary mb-4" type="button" @click.prevent="generateNewKeyPair">{{ $t('keygen.button-generate') }}</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="col-12 col-lg-2 mt-sm-4">
|
||||
</div>
|
||||
<div class="col-12 col-lg-5">
|
||||
<h1>{{ $t('keygen.headline-preshared-key') }}</h1>
|
||||
<fieldset>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('keygen.preshared-key.label') }}</label>
|
||||
<input class="form-control" v-model="presharedKey" :placeholder="$t('keygen.preshared-key.placeholder')" readonly>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<hr class="mt-4">
|
||||
<button class="btn btn-primary mb-4" type="button" @click.prevent="generateNewPresharedKey">{{ $t('keygen.button-generate') }}</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
@@ -393,8 +393,14 @@ func (r *WgRepo) getOrCreatePeer(deviceId domain.InterfaceIdentifier, id domain.
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peer create error for %s: %w", id.ToPublicKey(), err)
|
||||
}
|
||||
|
||||
peer, err = r.getPeer(deviceId, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peer error after create: %w", err)
|
||||
}
|
||||
return peer, nil
|
||||
}
|
||||
|
||||
|
203
internal/app/gorm_encryption.go
Normal file
203
internal/app/gorm_encryption.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
// GormEncryptedStringSerializer is a GORM serializer that encrypts and decrypts string values using AES256.
|
||||
// It is used to store sensitive information in the database securely.
|
||||
// If the serializer encounters a value that is not a string, it will return an error.
|
||||
type GormEncryptedStringSerializer struct {
|
||||
useEncryption bool
|
||||
keyPhrase string
|
||||
prefix string
|
||||
}
|
||||
|
||||
// NewGormEncryptedStringSerializer creates a new GormEncryptedStringSerializer.
|
||||
// It needs to be registered with GORM to be used:
|
||||
// schema.RegisterSerializer("encstr", gormEncryptedStringSerializerInstance)
|
||||
// You can then use it in your model like this:
|
||||
//
|
||||
// EncryptedField string `gorm:"serializer:encstr"`
|
||||
func NewGormEncryptedStringSerializer(keyPhrase string) GormEncryptedStringSerializer {
|
||||
return GormEncryptedStringSerializer{
|
||||
useEncryption: keyPhrase != "",
|
||||
keyPhrase: keyPhrase,
|
||||
prefix: "WG_ENC_",
|
||||
}
|
||||
}
|
||||
|
||||
// Scan implements the GORM serializer interface. It decrypts the value after reading it from the database.
|
||||
func (s GormEncryptedStringSerializer) Scan(
|
||||
ctx context.Context,
|
||||
field *schema.Field,
|
||||
dst reflect.Value,
|
||||
dbValue any,
|
||||
) (err error) {
|
||||
var dbStringValue string
|
||||
if dbValue != nil {
|
||||
switch v := dbValue.(type) {
|
||||
case []byte:
|
||||
dbStringValue = string(v)
|
||||
case string:
|
||||
dbStringValue = v
|
||||
default:
|
||||
return fmt.Errorf("unsupported type %T for encrypted field %s", dbValue, field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if !s.useEncryption {
|
||||
field.ReflectValueOf(ctx, dst).SetString(dbStringValue) // keep the original value
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(dbStringValue, s.prefix) {
|
||||
field.ReflectValueOf(ctx, dst).SetString(dbStringValue) // keep the original value
|
||||
return nil
|
||||
}
|
||||
|
||||
encryptedString := strings.TrimPrefix(dbStringValue, s.prefix)
|
||||
decryptedString, err := DecryptAES256(encryptedString, s.keyPhrase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt value for field %s: %w", field.Name, err)
|
||||
}
|
||||
|
||||
field.ReflectValueOf(ctx, dst).SetString(decryptedString)
|
||||
return
|
||||
}
|
||||
|
||||
// Value implements the GORM serializer interface. It encrypts the value before storing it in the database.
|
||||
func (s GormEncryptedStringSerializer) Value(
|
||||
_ context.Context,
|
||||
_ *schema.Field,
|
||||
_ reflect.Value,
|
||||
fieldValue any,
|
||||
) (any, error) {
|
||||
if fieldValue == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch v := fieldValue.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
return "", nil // empty string, no need to encrypt
|
||||
}
|
||||
if !s.useEncryption {
|
||||
return v, nil // keep the original value
|
||||
}
|
||||
encryptedString, err := EncryptAES256(v, s.keyPhrase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.prefix + encryptedString, nil
|
||||
case domain.PreSharedKey:
|
||||
if v == "" {
|
||||
return "", nil // empty string, no need to encrypt
|
||||
}
|
||||
if !s.useEncryption {
|
||||
return string(v), nil // keep the original value
|
||||
}
|
||||
encryptedString, err := EncryptAES256(string(v), s.keyPhrase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.prefix + encryptedString, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("encryption only supports string values, got %T", fieldValue)
|
||||
}
|
||||
}
|
||||
|
||||
// EncryptAES256 encrypts the given plaintext with the given key using AES256 in CBC mode with PKCS7 padding
|
||||
func EncryptAES256(plaintext, key string) (string, error) {
|
||||
if len(plaintext) == 0 {
|
||||
return "", fmt.Errorf("plaintext must not be empty")
|
||||
}
|
||||
if len(key) == 0 {
|
||||
return "", fmt.Errorf("key must not be empty")
|
||||
}
|
||||
key = trimEncKey(key)
|
||||
iv := key[:aes.BlockSize]
|
||||
|
||||
block, err := aes.NewCipher([]byte(key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
plain := []byte(plaintext)
|
||||
plain = pkcs7Padding(plain, aes.BlockSize)
|
||||
|
||||
ciphertext := make([]byte, len(plain))
|
||||
|
||||
mode := cipher.NewCBCEncrypter(block, []byte(iv))
|
||||
mode.CryptBlocks(ciphertext, plain)
|
||||
|
||||
b64String := base64.StdEncoding.EncodeToString(ciphertext)
|
||||
|
||||
return b64String, nil
|
||||
}
|
||||
|
||||
// DecryptAES256 decrypts the given ciphertext with the given key using AES256 in CBC mode with PKCS7 padding
|
||||
func DecryptAES256(encrypted, key string) (string, error) {
|
||||
if len(encrypted) == 0 {
|
||||
return "", fmt.Errorf("ciphertext must not be empty")
|
||||
}
|
||||
if len(key) == 0 {
|
||||
return "", fmt.Errorf("key must not be empty")
|
||||
}
|
||||
key = trimEncKey(key)
|
||||
iv := key[:aes.BlockSize]
|
||||
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return "", fmt.Errorf("invalid ciphertext length, must be a multiple of %d", aes.BlockSize)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher([]byte(key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mode := cipher.NewCBCDecrypter(block, []byte(iv))
|
||||
mode.CryptBlocks(ciphertext, ciphertext)
|
||||
|
||||
ciphertext = pkcs7UnPadding(ciphertext)
|
||||
|
||||
return string(ciphertext), nil
|
||||
}
|
||||
|
||||
func pkcs7Padding(ciphertext []byte, blockSize int) []byte {
|
||||
padding := blockSize - len(ciphertext)%blockSize
|
||||
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
return append(ciphertext, padtext...)
|
||||
}
|
||||
|
||||
func pkcs7UnPadding(src []byte) []byte {
|
||||
length := len(src)
|
||||
unpadding := int(src[length-1])
|
||||
return src[:(length - unpadding)]
|
||||
}
|
||||
|
||||
func trimEncKey(key string) string {
|
||||
if len(key) > 32 {
|
||||
return key[:32]
|
||||
}
|
||||
|
||||
if len(key) < 32 {
|
||||
key = key + strings.Repeat("0", 32-len(key))
|
||||
}
|
||||
return key
|
||||
}
|
@@ -190,7 +190,7 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
|
||||
return nil, fmt.Errorf("failed to prepare peer for interface %s: %w", peer.InterfaceIdentifier, err)
|
||||
}
|
||||
|
||||
preparedPeer.OverwriteUserEditableFields(peer)
|
||||
preparedPeer.OverwriteUserEditableFields(peer, m.cfg)
|
||||
|
||||
peer = preparedPeer
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func (m Manager) UpdatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to load existing peer %s: %w", peer.Identifier, err)
|
||||
}
|
||||
originalPeer.OverwriteUserEditableFields(peer)
|
||||
originalPeer.OverwriteUserEditableFields(peer, m.cfg)
|
||||
|
||||
peer = originalPeer
|
||||
}
|
||||
|
@@ -174,9 +174,16 @@ func GetConfig() (*Config, error) {
|
||||
|
||||
// override config values from YAML file
|
||||
|
||||
cfgFileName := "config/config.yml"
|
||||
cfgFileName := "config/config.yaml"
|
||||
cfgFileNameFallback := "config/config.yml"
|
||||
if envCfgFileName := os.Getenv("WG_PORTAL_CONFIG"); envCfgFileName != "" {
|
||||
cfgFileName = envCfgFileName
|
||||
cfgFileNameFallback = envCfgFileName
|
||||
}
|
||||
|
||||
// check if the config file exists, otherwise use the fallback file name
|
||||
if _, err := os.Stat(cfgFileName); os.IsNotExist(err) {
|
||||
cfgFileName = cfgFileNameFallback
|
||||
}
|
||||
|
||||
if err := loadConfigFile(cfg, cfgFileName); err != nil {
|
||||
|
@@ -18,11 +18,14 @@ type DatabaseConfig struct {
|
||||
// Debug enables logging of all database statements
|
||||
Debug bool `yaml:"debug"`
|
||||
// SlowQueryThreshold enables logging of slow queries which take longer than the specified duration
|
||||
SlowQueryThreshold time.Duration `yaml:"slow_query_threshold"` // 0 means no logging of slow queries
|
||||
SlowQueryThreshold time.Duration `yaml:"slow_query_threshold"` // "0" means no logging of slow queries
|
||||
// Type is the database type. Supported: mysql, mssql, postgres, sqlite
|
||||
Type SupportedDatabase `yaml:"type"`
|
||||
// DSN is the database connection string.
|
||||
// For SQLite, it is the path to the database file.
|
||||
// For other databases, it is the connection string, see: https://gorm.io/docs/connecting_to_the_database.html
|
||||
DSN string `yaml:"dsn"`
|
||||
// EncryptionPassphrase is the passphrase used to encrypt sensitive data (WireGuard keys) in the database.
|
||||
// If no passphrase is provided, no encryption will be used.
|
||||
EncryptionPassphrase string `yaml:"encryption_passphrase"`
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
type KeyPair struct {
|
||||
PrivateKey string
|
||||
PrivateKey string `gorm:"serializer:encstr"`
|
||||
PublicKey string
|
||||
}
|
||||
|
||||
|
@@ -9,6 +9,7 @@ import (
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
)
|
||||
|
||||
type PeerIdentifier string
|
||||
@@ -35,7 +36,7 @@ type Peer struct {
|
||||
EndpointPublicKey ConfigOption[string] `gorm:"embedded;embeddedPrefix:endpoint_pubkey_"` // the endpoint public key
|
||||
AllowedIPsStr ConfigOption[string] `gorm:"embedded;embeddedPrefix:allowed_ips_str_"` // all allowed ip subnets, comma seperated
|
||||
ExtraAllowedIPsStr string // all allowed ip subnets on the server side, comma seperated
|
||||
PresharedKey PreSharedKey // the pre-shared Key of the peer
|
||||
PresharedKey PreSharedKey `gorm:"serializer:encstr"` // the pre-shared Key of the peer
|
||||
PersistentKeepalive ConfigOption[int] `gorm:"embedded;embeddedPrefix:persistent_keep_alive_"` // the persistent keep-alive interval
|
||||
|
||||
// WG Portal specific
|
||||
@@ -129,16 +130,18 @@ func (p *Peer) GenerateDisplayName(prefix string) {
|
||||
}
|
||||
|
||||
// OverwriteUserEditableFields overwrites the user editable fields of the peer with the values from the userPeer
|
||||
func (p *Peer) OverwriteUserEditableFields(userPeer *Peer) {
|
||||
func (p *Peer) OverwriteUserEditableFields(userPeer *Peer, cfg *config.Config) {
|
||||
p.DisplayName = userPeer.DisplayName
|
||||
p.Interface.PublicKey = userPeer.Interface.PublicKey
|
||||
p.Interface.PrivateKey = userPeer.Interface.PrivateKey
|
||||
if cfg.Core.EditableKeys {
|
||||
p.Interface.PublicKey = userPeer.Interface.PublicKey
|
||||
p.Interface.PrivateKey = userPeer.Interface.PrivateKey
|
||||
p.PresharedKey = userPeer.PresharedKey
|
||||
}
|
||||
p.Interface.Mtu = userPeer.Interface.Mtu
|
||||
p.PersistentKeepalive = userPeer.PersistentKeepalive
|
||||
p.ExpiresAt = userPeer.ExpiresAt
|
||||
p.Disabled = userPeer.Disabled
|
||||
p.DisabledReason = userPeer.DisabledReason
|
||||
p.PresharedKey = userPeer.PresharedKey
|
||||
}
|
||||
|
||||
type PeerInterfaceConfig struct {
|
||||
|
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
)
|
||||
|
||||
func TestPeer_IsDisabled(t *testing.T) {
|
||||
@@ -98,7 +99,7 @@ func TestPeer_OverwriteUserEditableFields(t *testing.T) {
|
||||
DisplayName: "New DisplayName",
|
||||
}
|
||||
|
||||
peer.OverwriteUserEditableFields(userPeer)
|
||||
peer.OverwriteUserEditableFields(userPeer, &config.Config{})
|
||||
assert.Equal(t, "New DisplayName", peer.DisplayName)
|
||||
}
|
||||
|
||||
|
@@ -65,6 +65,7 @@ nav:
|
||||
- Docker: documentation/getting-started/docker.md
|
||||
- Helm: documentation/getting-started/helm.md
|
||||
- Sources: documentation/getting-started/sources.md
|
||||
- Reverse Proxy (HTTPS): documentation/getting-started/reverse-proxy.md
|
||||
- Configuration:
|
||||
- Overview: documentation/configuration/overview.md
|
||||
- Examples: documentation/configuration/examples.md
|
||||
|
Reference in New Issue
Block a user