Compare commits

...

74 commits

Author SHA1 Message Date
9068151e9e Pin LoveIt starter to v0.3.1 2026-03-19 17:49:52 +07:00
a6724b1575 Initialize LoveIt starter submodules 2026-03-19 14:57:07 +07:00
8383eb5101 Fix LoveIt starter branch name 2026-03-19 14:54:18 +07:00
87c4a3c357 Pin LoveIt starter to 0.3.X 2026-03-19 14:48:07 +07:00
752d80e11c Automate blog deploy user setup 2026-03-19 14:38:22 +07:00
62593d8316 Add Hugo blog starter and Forgejo post workflows 2026-03-19 13:32:15 +07:00
fd7398be9f Refactor installer menus and update project paths
All checks were successful
Docker Cloud Build / Build & Publish Image (push) Successful in 1m7s
2026-03-19 12:56:05 +07:00
14c4e1ee5c Add AI project map and init workflow for faster repo context 2026-03-19 12:20:58 +07:00
680c9261f6 ci: remove QEMU and multi-platform build for native optimization
All checks were successful
Docker Cloud Build / Build & Publish Image (push) Successful in 1m6s
2026-03-19 00:27:28 +07:00
554130afbb feat(security): implement rate limiting and helmet security headers
All checks were successful
Docker Cloud Build / Build & Publish Image (push) Successful in 11m11s
- Add express-rate-limit: login limiter (10 req/15m) and global limiter (100 req/m)
- Add helmet: secure HTTP headers with custom CSP configuration
- Remove manual header settings in favor of helmet
2026-03-19 00:11:31 +07:00
547360d0e3 ci: fix docker cache error by lowercasing image name 2026-03-19 00:08:48 +07:00
1ac4c5e1db ci: add BuildKit registry cache (mode=max) to speed up Docker builds
Some checks failed
Docker Cloud Build / Build & Publish Image (push) Failing after 10m21s
2026-03-19 00:00:12 +07:00
adf738bc33 fix(audit-log): remove invalid defaultMessage prop from T component in pagination
All checks were successful
Docker Cloud Build / Build & Publish Image (push) Successful in 11m13s
2026-03-18 23:45:47 +07:00
9b5152d81f feat(security): AES-256-GCM encryption for WireGuard private keys in DB
Some checks failed
Docker Cloud Build / Build & Publish Image (push) Failing after 10m4s
- Add backend/lib/crypto.js: transparent encrypt/decrypt with DB_ENCRYPTION_KEY env var
- Add migration 20260319000000: idempotent data migration encrypts existing plaintext keys
- Patch wireguard.js: encrypt on write (3 points), decrypt on read (4 points)
- install.sh: auto-generate DB_ENCRYPTION_KEY via openssl, save to .env (chmod 600)
- AI_CONTEXT.md: document crypto.js and DB_ENCRYPTION_KEY requirement
2026-03-18 23:21:00 +07:00
44f0a080ff feat(audit-log): add pagination, global search filter and CSV export
Some checks failed
Docker Cloud Build / Build & Publish Image (push) Failing after 10m11s
2026-03-18 22:43:01 +07:00
9929d77326 fix(ci): install yarn via npm before frontend build (catthehacker image lacks yarn)
All checks were successful
Docker Cloud Build / Build & Publish Image (push) Successful in 11m55s
2026-03-18 22:20:20 +07:00
91fe419821 fix(runner): use catthehacker/ubuntu:act-22.04 image (has Node.js + Docker CLI for CI actions) 2026-03-18 22:05:56 +07:00
144eb9e5d5 ci: trigger pipeline rebuild 2026-03-18 22:02:24 +07:00
d73582e75d fix(runner): install Node.js 20 on host before starting runner (required for JS actions in host mode) 2026-03-18 21:59:07 +07:00
9a5325a38d fix(runner): install Docker deps before checking/starting runner container 2026-03-18 21:49:00 +07:00
e9367b535d fix(runner): switch GITEA_RUNNER_LABELS to host mode to fix Docker not found in CI 2026-03-18 19:21:04 +07:00
xtcnet
23f197aeb1 feat: auto-tune swap and Node.js memory based on VPS RAM
Some checks failed
Docker Cloud Build / Build & Publish Image (push) Has been cancelled
install.sh: detect RAM at runner install time and create swap only when
needed (<2GB → 2G swap, 2-4GB → 1G swap, >4GB → no swap).

workflow: detect RAM at build time and set NODE_OPTIONS accordingly
(<2GB → 768MB, 2-4GB → 1536MB, >4GB → 3072MB).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:30:17 +07:00
xtcnet
89c9ed842f fix: support 1GB RAM VPS for Docker image builds
Some checks failed
Docker Cloud Build / Build & Publish Image (push) Has been cancelled
- workflow: limit Node.js to 768MB (NODE_OPTIONS --max-old-space-size)
  and remove GitHub Actions cache (not supported on Forgejo Actions)
- install: auto-create 2GB swapfile when installing Forgejo Runner so
  the build process does not OOM on low-RAM machines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:24:53 +07:00
xtcnet
2345f10b21 fix(install): use env vars for act_runner auto-registration
gitea/act_runner entrypoint auto-registers on first start using
GITEA_INSTANCE_URL and GITEA_RUNNER_REGISTRATION_TOKEN env vars,
then starts the daemon. Remove the separate register step entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:21:35 +07:00
xtcnet
376d27367c fix(install): remove duplicate act_runner prefix in runner commands
gitea/act_runner sets act_runner as the container entrypoint, so the
register and daemon subcommands must be passed directly without the
binary name prefix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:20:05 +07:00
xtcnet
9536c8a75a fix(install): switch Forgejo Runner to gitea/act_runner from Docker Hub
code.forgejo.org/forgejo/runner does not have usable version tags.
Use gitea/act_runner:latest from Docker Hub which is fully compatible
with Forgejo Actions. Update register/daemon commands accordingly
(act_runner instead of forgejo-runner, --instance instead of --url).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:18:22 +07:00
xtcnet
4ec63f0fe8 feat(install): auto-update install.sh on every run at startup
Move self-update logic from do_update() to a self_update() helper called
at the entry point before showing the menu or running any command.
The script now checks for a newer version on every execution, re-execs
with the original arguments if an update is found, and is a no-op if
unreachable or already up to date.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:16:50 +07:00
xtcnet
c8801b97c6 fix(install): use versioned tag for Forgejo Runner image
code.forgejo.org/forgejo/runner does not publish a 'latest' tag.
Switch to stable major version tag ':3'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:15:14 +07:00
xtcnet
71f5477db3 docs: update README for Forgejo migration and new features
- Update install script URL to src.d3v.ac
- Update Docker image reference to src.d3v.ac/xtcnet/d3v-server:latest
- Update git clone URL to Forgejo
- Add Forgejo integration section (optional Git server + CI/CD)
- Add encrypted file storage and client isolation to feature list
- Update menu options to reflect current install.sh (8 options + Forgejo submenu)
- Update CI/CD section to describe Forgejo Actions workflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:13:01 +07:00
xtcnet
b8d64b150c feat: switch Docker registry from ghcr.io to Forgejo (src.d3v.ac)
Some checks failed
Docker Cloud Build / Build & Publish Image (push) Has been cancelled
- Update workflow REGISTRY to src.d3v.ac and use FORGEJO_TOKEN for auth
- Update IMAGE_NAME in install.sh to src.d3v.ac/xtcnet/d3v-server:latest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:07:41 +07:00
xtcnet
4c0d3952cb chore: update install.sh references from GitHub to Forgejo
- Update header comment URL to src.d3v.ac/xtcnet/D3V-Server
- Update self-update URL in do_update to fetch install.sh from Forgejo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:06:47 +07:00
xtcnet
91e493d81f feat(install): add Forgejo Runner install/uninstall to Forgejo submenu
- Add FORGEJO_RUNNER_DIR, FORGEJO_RUNNER_CONTAINER, FORGEJO_RUNNER_IMAGE constants
- do_forgejo_runner_install: prompts for Forgejo URL and token, registers
  runner with ubuntu-latest/ubuntu-22.04 Docker labels, starts daemon
- do_forgejo_runner_uninstall: stops/removes container and data directory
- Extend Forgejo submenu to 6 options (added Install/Uninstall Runner)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:05:15 +07:00
xtcnet
649d252a0f feat(install): unblock ports 3000 and 2222 when Forgejo is uninstalled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:45:25 +07:00
xtcnet
4369b1a3e4 feat(install): block port 2222, auto-save iptables rules on reboot
- Block port 2222 (Forgejo SSH) alongside 3000 after install since
  git operations use HTTPS via NPM proxy only
- Add save_iptables_rules helper: uses netfilter-persistent if present,
  otherwise writes /etc/iptables/rules.v4 and installs iptables-persistent
  so DROP rules survive reboots
- Call save_iptables_rules after Forgejo port block and toggle-port-81

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:44:36 +07:00
xtcnet
50dff1712e feat(install): block port 3000 after Forgejo install, English instructions
- Automatically add iptables DOCKER-USER DROP rule for port 3000 so
  Forgejo is only reachable via NPM proxy, not directly from the internet
- Rewrite post-install instructions in English with all 6 NPM setup steps
  including SSL config and correct ROOT_URL / SSH Port values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:43:37 +07:00
xtcnet
6ef729eb45 fix(install): use versioned Forgejo image tag instead of latest
Codeberg container registry does not publish a 'latest' tag for Forgejo.
Switch to the stable major version tag 'codeberg.org/forgejo/forgejo:9'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:35:47 +07:00
xtcnet
2e9ed07708 fix(install): skip d3v-net in compose when Forgejo is not installed
generate_docker_compose now only adds the d3v-net network section if the
network actually exists on the host. Servers without Forgejo no longer
fail with "network declared as external, but could not be found".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:31:13 +07:00
xtcnet
04a22dcc7d ci: skip Docker build when only non-image files change
Add paths filter so the build only triggers on changes to backend/,
frontend/, docker/, or the workflow file itself. Edits to install.sh,
CLAUDE.md, etc. no longer cause unnecessary image rebuilds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:28:35 +07:00
xtcnet
ce0d4f7611 feat(install): add Forgejo submenu with install/uninstall/update
- Add Forgejo as option 8 in main menu with submenu (install/uninstall/update)
- do_forgejo_install: creates /opt/forgejo with SQLite, ports 3000/2222,
  joins d3v-net network so NPM can proxy to it
- ensure_docker_network: creates d3v-net external network if missing
- On Forgejo install, regenerate D3V-NPMWG compose to include d3v-net
  and connect running container immediately (no restart required)
- Success output includes step-by-step NPM Proxy Host setup guide
- DOCKER_NETWORK constant (d3v-net) shared across both stacks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:25:48 +07:00
xtcnet
6c3122d03d feat(wg-public): add file manager UI with upload, rename, delete
- Add File Manager card above REST API Documentation on /wg-public page
  with table showing name, size, modified date and action buttons
- Upload: file picker button, enforces storage quota
- Rename: inline editable row (Enter to confirm, Escape to cancel)
- Delete: with confirmation dialog
- Download: opens decrypted file in new tab
- Add renameFile() method to wireguard-fs.js (fs.rename, no re-encryption)
- Add PATCH /api/wg-public/files/:filename endpoint for rename
- Fix bug: saveEncryptedFile -> uploadFile in wg_public.js
- Fix bug: getDecryptedFileStream + pipe -> downloadFile in wg_public.js
- Add Rename curl example to REST API Documentation section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:57:49 +07:00
xtcnet
dd525adaef feat(login): show xGat3 button only when accessed via IP address
Hide the Open xGat3 button when the page is accessed via a domain name.
Only show it when the hostname is a raw IP (e.g. 10.0.0.1 over WireGuard).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:44:02 +07:00
xtcnet
1b97b8b0ad fix(login): update xGat3 button link to /wg-public
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:43:11 +07:00
xtcnet
b49bcf90cb feat(login): add Open xGat3 button on login page
Adds a button below the login card that opens the xGat3 reverse
proxy interface (port 80) in a new tab when accessed via WireGuard VPN.
The link is built dynamically from the current hostname so it works
regardless of which IP the client uses to reach the server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:42:00 +07:00
xtcnet
5d65eafc65 fix: allow client-to-client traffic when isolation is disabled
The wg+ wildcard in the server isolation DROP rule was also matching
same-interface traffic (wg0->wg0), blocking clients from pinging each
other even with Client Isolation turned off.

Fix: always insert an explicit same-interface ACCEPT (or REJECT if
isolated) rule AFTER the wg+ DROP, so it lands at position 1 in the
chain and is evaluated before the DROP.

Also update syncIptablesRules to apply the ACCEPT rule (not just remove
the REJECT) when isolation is toggled off at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:14:33 +07:00
xtcnet
fd8baf878c fix: apply client isolation iptables rules immediately on config save
wg syncconf does not execute PostUp/PostDown, so toggling isolate_clients
had no effect until container restart. Add syncIptablesRules() to directly
apply/remove the REJECT rule after every syncconf call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:02:53 +07:00
xtcnet
08ce4b8390 fix: sửa lỗi và cải thiện tính năng Reset Admin Password
- Sửa lỗi db.js export getInstance() thay vì knex instance trực tiếp
- Tìm admin theo user đầu tiên trong DB thay vì hard-code id=1
- Chỉ hỏi mật khẩu mới, hiển thị email sau khi update thành công

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 19:51:09 +07:00
xtcnet
335490ac06 Fix: Resolve React Error #31 and missing date rendering in WireGuard Client Logs Modal 2026-03-10 19:12:24 +07:00
xtcnet
090894021a Refactor: Standardize units to GB/MB, fix Dashboard live traffic aggregation, and optimize WireGuard client layout with expanded actions. 2026-03-10 19:02:44 +07:00
xtcnet
b77da8e6de feat(wireguard): massive scale extensions for Quotas, Web Dashboards, Connection Logs, and Zero-Auth Public VPN file portals 2026-03-10 13:09:51 +07:00
xtcnet
66dc95bc6b fix(wireguard): resolve 500 parse failure on encrypted file upload stream memory buffers 2026-03-10 11:53:20 +07:00
xtcnet
787e3bb243 fix(wireguard): resolve empty auth token on client file upload 2026-03-10 11:45:12 +07:00
xtcnet
bd04298843 feat(wireguard): add isolated encrypted file manager per wg client, drop sql editor 2026-03-10 11:40:19 +07:00
xtcnet
e057aee8ba feat(wireguard): harden security constraints and fix db manager UI 2026-03-10 11:25:40 +07:00
xtcnet
b99b623355 feat(database): add native SQLite database manager and fix wireguard admin visibility 2026-03-10 10:58:08 +07:00
xtcnet
3f0d529d14 fix(wireguard): isolate user data by owner_user_id 2026-03-10 10:39:46 +07:00
xtcnet
d67081492d fix: map interfaceId to WireGuard client response 2026-03-08 20:55:47 +07:00
xtcnet
497482aef3 fix: WireGuard client filter, feat: system monitor storage and total ram 2026-03-08 20:49:00 +07:00
xtcnet
e48fef3154 feat: real-time system monitor in footer 2026-03-08 20:35:06 +07:00
xtcnet
34020bc562 feat: custom Stream port manager UI and WireGuard config Zip download API 2026-03-08 15:50:25 +07:00
xtcnet
7bf175da41 fix: revert from host to bridge network mode to bypass external firewalls automatically 2026-03-08 15:13:32 +07:00
xtcnet
2cbaab23c5 fix: remove sysctls from host network container and apply them to host OS 2026-03-08 15:01:48 +07:00
xtcnet
9eeb3f7c7d feat: centralize compose generation and add self-update to install script 2026-03-08 14:58:47 +07:00
xtcnet
a0edaccfc4 feat: script auto-migrates old docker-compose ports to host network mode on update 2026-03-08 14:53:19 +07:00
xtcnet
af5cfbea84 feat: switch default docker compose template to network_mode host 2026-03-08 14:42:57 +07:00
xtcnet
f5323ce8fa fix: translation variables and WireGuard client filtering 2026-03-08 14:30:22 +07:00
xtcnet
8c91886de6 fix: remove unused variables causing TypeScript build failure 2026-03-08 14:21:34 +07:00
xtcnet
ec55362d15 feat: fix audit log display, add dashboard counts, restructure WireGuard page, add translations 2026-03-08 14:17:18 +07:00
xtcnet
f8ad3fe807 docs: update multi-server docker port mapping instructions to 51820-51830/udp 2026-03-08 11:18:05 +07:00
xtcnet
f9d687c131 fix: resolve multi-server iptables bridging and hook audit logging 2026-03-08 10:58:19 +07:00
xtcnet
dd8dd605f1 fix: resolve 500 error on server creation due to ipv6_cidr schema violation 2026-03-08 10:45:19 +07:00
xtcnet
3960d6025f fix: resolve 404 on server creation and 500 on client creation and reposition buttons to tables 2026-03-08 10:39:17 +07:00
xtcnet
5f4acb755e fix: resolve cancel and close buttons not working on server modals 2026-03-08 10:29:37 +07:00
xtcnet
36acc3ea65 fix: resolve WireGuard server tab crash and enforce client creation server prerequisite 2026-03-08 09:47:20 +07:00
xtcnet
54d1623551 feat: implement wireguard multi-server UI and backend logic 2026-03-08 09:33:24 +07:00
121 changed files with 13086 additions and 1620 deletions

@ -0,0 +1 @@
Subproject commit 288a767d8b251004f7bd7999dcdf408cbbaa86c7

64
.agents/workflows/init.md Normal file
View file

@ -0,0 +1,64 @@
---
description: Initialize agent context for the D3V-Server project
---
## /init Workflow
Use this workflow at the start of a new session.
Goal: understand the repo quickly without scanning the full codebase.
### Required first step
1. Read `D:\AntiGravity\D3V-Server\AI_CONTEXT.md`
Do not start with a broad recursive scan unless the user explicitly asks for a repo-wide audit.
### Minimal orientation sequence
After reading `AI_CONTEXT.md`, open only these files:
1. `D:\AntiGravity\D3V-Server\README.md`
2. `D:\AntiGravity\D3V-Server\backend\index.js`
3. `D:\AntiGravity\D3V-Server\backend\app.js`
4. `D:\AntiGravity\D3V-Server\backend\routes\main.js`
5. `D:\AntiGravity\D3V-Server\frontend\src\main.tsx`
6. `D:\AntiGravity\D3V-Server\frontend\src\App.tsx`
7. `D:\AntiGravity\D3V-Server\frontend\src\Router.tsx`
8. `D:\AntiGravity\D3V-Server\docker\Dockerfile`
That set is normally enough to understand:
- top-level architecture
- backend startup flow
- frontend startup flow
- route structure
- production packaging model
### Area-specific follow-up
Only then open files for the requested area:
- Backend/API task: inspect `backend/routes/`, `backend/internal/`, `backend/models/`
- Frontend/UI task: inspect `frontend/src/pages/`, `frontend/src/hooks/`, `frontend/src/api/`
- WireGuard task: inspect `backend/internal/wireguard.js`, `backend/routes/wireguard.js`, `backend/routes/wg_public.js`, `frontend/src/pages/WireGuard/`
- Build/deploy task: inspect `docker/`, `scripts/`, `install.sh`
- Docs task: inspect `docs/`
- Test task: inspect `test/`
### Important reminders
- This is a multi-package repo. Commands run in subdirectories, not from one obvious root workspace.
- Docker packaging is part of the product behavior, not just deployment.
- The frontend must be built before the production Docker image can be built.
- Some runtime behavior depends on env vars, mounted volumes, and container capabilities.
- Avoid rewriting upstream-shaped structure unless the task requires it.
### Ready message
When initialization is complete, summarize the project in 3-5 lines:
- what the app is
- backend entry
- frontend entry
- production assembly path
- the specific area relevant to the user's task

View file

@ -1,6 +1,6 @@
# AI Assistant Instructions
You are working on **NPM-WG** (Nginx Proxy Manager + WireGuard).
You are working on **NPM-WG** (xGat3 + WireGuard).
Whenever you start a task in this workspace or are asked to fix a bug, please **FIRST READ the file `AI_CONTEXT.md`** at the root of the project.
It contains:

4
.dockerignore Normal file
View file

@ -0,0 +1,4 @@
node_modules/
frontend/node_modules/
backend/node_modules/
dist/

View file

@ -27,8 +27,8 @@ Are you in the right place?
<!-- A clear and concise description of what the bug is. -->
**Nginx Proxy Manager Version**
<!-- What version of Nginx Proxy Manager is reported on the login page? -->
**xGat3 Version**
<!-- What version of xGat3 is reported on the login page? -->
**To Reproduce**

View file

@ -4,11 +4,21 @@ on:
push:
branches: [ "master" ]
tags: [ 'v*.*.*' ]
paths:
- 'backend/**'
- 'frontend/**'
- 'docker/**'
- '.github/workflows/docker-publish.yml'
pull_request:
branches: [ "master" ]
paths:
- 'backend/**'
- 'frontend/**'
- 'docker/**'
- '.github/workflows/docker-publish.yml'
env:
REGISTRY: ghcr.io
REGISTRY: src.d3v.ac
IMAGE_NAME: ${{ github.repository }}
jobs:
@ -23,9 +33,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@ -33,12 +40,21 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'yarn'
cache-dependency-path: frontend/yarn.lock
- name: Install Frontend Dependencies & Build
run: |
total_ram_mb=$(awk '/MemTotal/ { printf "%d", $2/1024 }' /proc/meminfo)
if [ "$total_ram_mb" -lt 2048 ]; then
node_mem=768
elif [ "$total_ram_mb" -lt 4096 ]; then
node_mem=1536
else
node_mem=3072
fi
echo "RAM: ${total_ram_mb}MB — setting Node.js max to ${node_mem}MB"
export NODE_OPTIONS="--max-old-space-size=${node_mem}"
cd frontend
npm install -g yarn
yarn install --frozen-lockfile
yarn locale-compile
yarn build
@ -49,7 +65,7 @@ jobs:
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
password: ${{ secrets.FORGEJO_TOKEN }}
- name: Extract Docker metadata
id: meta
@ -61,6 +77,10 @@ jobs:
type=semver,pattern={{version}}
type=sha
- name: Lowercase IMAGE_NAME
run: |
echo "IMAGE_NAME_LOWER=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
@ -70,5 +90,5 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LOWER }}:buildcache
cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}/{1}:buildcache,mode=max', env.REGISTRY, env.IMAGE_NAME_LOWER) || '' }}

View file

@ -1,64 +1,328 @@
# AI Context for NPM-WG Project
# AI Context for D3V-Server
## 1. Project Overview
**NPM-WG** is a custom fork of [Nginx Proxy Manager](https://github.com/NginxProxyManager/nginx-proxy-manager) integrated with **WireGuard VPN** management capabilities, inspired by `wg-easy`.
This file is the fast-start map for AI agents working in this repository.
Read this first. Do not scan the entire repo unless the task actually requires it.
The project structure remains mostly identical to Nginx Proxy Manager, but specific backend and frontend modules have been added to manage WireGuard securely inside the Docker container without needing external dependencies.
## 1. What this project is
---
`D3V-Server` is a custom fork of Nginx Proxy Manager / xGat3 with an added
WireGuard management module and some project-specific admin features.
## 2. Technology Stack
- **Backend**: Node.js, Express.js, Knex (Query Builder), SQLite/MySQL/PostgreSQL. Uses ES Modules (`"type": "module"`).
- **Frontend**: React 18, TypeScript, Vite, React Router, React Bootstrap (`ez-modal-react`), Formik, React Query (`@tanstack/react-query`).
- **Container**: Alpine Linux with `s6-overlay` for service process management.
Canonical repository:
---
- `https://src.d3v.ac/d3v/D3V-Server`
## 3. WireGuard Integration Architecture
Treat that repository as the source of truth for this project.
If another upstream is mentioned, assume it is a historical ancestor or reference,
not the primary project remote, unless the task says otherwise.
### Core Idea
WireGuard functionality is disabled by default and enabled via the `WG_ENABLED` environment variable. The system uses a Node.js cron wrapper to manipulate the WireGuard `wg` and `wg-quick` CLI tools directly. It leverages Docker volume mapping (`/etc/wireguard`) to maintain state.
At a high level:
### Backend Map (Node.js)
If you need to edit WireGuard logic, check these files:
- **`backend/lib/wg-helpers.js`**: Shell wrappers for `wg` CLI (create keys, parse CIDR, parse `wg show` dumps, gen configurations).
- **`backend/internal/wireguard.js`**: Core business logic. Manages interface start/stop, adding/removing clients, IP allocation, and token expiration checking via cron.
- **`backend/routes/wireguard.js`**: REST APIs exposing CRUD operations to the frontend. Note: Handlers use ES module export functions syntax.
- **`backend/routes/main.js`**: Mounts the `/api/wireguard` routes.
- **`backend/index.js`**: Contains the startup hook `internalWireguard.startup(knex)` and graceful SIGTERM shutdown hooks.
- **`backend/migrations/20260307000000_wireguard.js`**: Knex schema initialization for tables `wg_interface` and `wg_client`. *Note: Must use ES Module `export function up()` instead of `exports.up`!*
- `backend/` is the API server and system orchestration layer.
- `frontend/` is the admin web UI.
- `docker/` is the production runtime assembly.
- `test/` contains Cypress and API-spec checks.
- `docs/` is a separate VitePress docs site.
### Frontend Map (React)
If you need to edit the UI/UX, check these files:
- **`frontend/src/api/backend/wireguard.ts`**: API fetch helper definitions.
- **`frontend/src/hooks/useWireGuard.ts`**: `@tanstack/react-query` data fetchers and mutators.
- **`frontend/src/pages/WireGuard/index.tsx`**: Main UI Page rendering the interface stats and clients table.
- **`frontend/src/modals/WireGuardClientModal.tsx`**: Form to create a new client. *Note: Modal built explicitly over `react-bootstrap/Modal` to prevent backdrop freezing issues.*
- **`frontend/src/modals/WireGuardQRModal.tsx`**: Generates and parses QR codes.
- **`frontend/src/Router.tsx` & `SiteMenu.tsx`**: Routing and UI Navigation injection points for WireGuard UI.
The production app is not "just backend" or "just frontend":
Docker assembles the backend and the prebuilt frontend into one image.
---
## 2. Repo map
## 4. Build & Deployment Gotchas
```text
D3V-Server/
|- backend/ Node.js + Express API, DB, WireGuard logic
| |- routes/ REST endpoints
| |- internal/ background/system services
| |- models/ Objection models
| |- migrations/ Knex migrations
| |- schema/ API schema compilation
| |- templates/ config/template generation
| |- app.js Express app wiring
| `- index.js backend startup entry
|- frontend/ React + TypeScript + Vite SPA
| |- src/
| | |- pages/ top-level screens
| | |- components/ shared UI
| | |- hooks/ data and UI hooks
| | |- api/ backend API wrappers
| | |- modals/ modal flows
| | |- context/ auth/theme/locale providers
| | |- Router.tsx route table
| | `- main.tsx frontend entry
| `- vite.config.ts dev/build config
|- docker/ production image and runtime scripts
|- docs/ VitePress docs site
|- test/ Cypress E2E and swagger lint
|- scripts/ helper scripts
|- install.sh install/update/reset automation for deployment
`- README.md user-facing overview
```
### Line Endings (CRLF vs LF)
- **CRITICAL**: All files in `docker/rootfs` and `docker/scripts` are used by `s6-overlay` inside Alpine Linux. **They MUST be formatted using UNIX Line Endings (LF)**. If you download this repository on Windows, ensure the git config does not automatically convert text files to `CRLF`, otherwise container booting will crash with `s6-rc-compile: fatal: invalid type: must be oneshot, longrun, or bundle`.
## 3. Runtime architecture
### Compilation Steps
- The React Frontend **MUST** be pre-built before Docker can build.
- You must run `yarn install`, `yarn locale-compile`, and `yarn build` inside the `frontend/` directory before `docker build`.
- Use the script `./scripts/build-project.sh` to execute the full pipeline if you have a bash environment.
```mermaid
flowchart LR
A["Browser"] --> B["Frontend SPA<br/>frontend/src/main.tsx"]
B --> C["React Router + pages<br/>frontend/src/Router.tsx"]
C --> D["Backend API<br/>backend/app.js"]
D --> E["Route handlers<br/>backend/routes/*"]
E --> F["Models / DB<br/>Knex + Objection"]
D --> G["Internal services<br/>backend/internal/*"]
G --> H["WireGuard CLI / system state"]
I["docker/Dockerfile"] --> D
I --> B
```
### Docker Config Requirements
- **Required capabilities**: `--cap-add=NET_ADMIN` and `--cap-add=SYS_MODULE` are required for WireGuard to manipulate interfaces.
- **Sysctls**: `--sysctl net.ipv4.ip_forward=1` must be applied to the container.
- **Volumes**: Volume `/etc/letsencrypt` is severely required by original NPM core.
## 4. First files to open for orientation
---
If a new session needs project understanding, open these files in order:
## 5. Agent Instructions
If you are an AI reading this file:
1. Treat existing NPM-specific code as sacred. Do not modify global `.ts` hooks or Knex config unless instructed.
2. If fixing a bug in the Frontend, use `useWgClients()` / `useInterfaceStatus()` standard hooks. Use React-Bootstrap `Modal` instead of raw div class names.
3. If changing the DB, create a new `backend/migrations/*.js` file in ES Module format.
4. When testing out scripts, remember that the docker container requires port mapping to 51820/udp.
1. `AI_CONTEXT.md`
2. `README.md`
3. `backend/index.js`
4. `backend/app.js`
5. `backend/routes/main.js`
6. `frontend/src/main.tsx`
7. `frontend/src/App.tsx`
8. `frontend/src/Router.tsx`
9. `docker/Dockerfile`
10. the relevant package manifest for the area being changed
That is usually enough to understand the system without scanning everything.
## 5. How the backend starts
Main entry: `backend/index.js`
Startup flow:
1. Run DB migrations via `migrateUp()`
2. Run setup/bootstrap via `setup()`
3. Compile/load schema via `getCompiledSchema()`
4. Optionally fetch IP ranges
5. Start timers for internal certificate/IP-range jobs
6. If `WG_ENABLED != false`, start WireGuard service hooks
7. Start Express server on port `3000`
Important implication:
- Many issues that look like "API bugs" can actually come from startup/setup/migration state.
- WireGuard startup failure is logged, but the backend may continue without WireGuard functionality.
## 6. How the backend is organized
Core files:
- `backend/index.js`: process startup and shutdown
- `backend/app.js`: Express middleware, helmet, rate limit, auth, route mounting
- `backend/routes/main.js`: root API router
- `backend/db.js`: DB handle creation
- `backend/lib/config.js`: database/config/env resolution
- `backend/setup.js`: instance bootstrap/setup flow
Important route groups from `backend/routes/main.js`:
- `/api/schema`
- `/api/tokens`
- `/api/users`
- `/api/audit-log`
- `/api/reports`
- `/api/settings`
- `/api/version`
- `/api/nginx/proxy-hosts`
- `/api/nginx/redirection-hosts`
- `/api/nginx/dead-hosts`
- `/api/nginx/streams`
- `/api/nginx/access-lists`
- `/api/nginx/certificates`
- `/api/wireguard`
- `/api/database`
Public WireGuard portal:
- `backend/app.js` mounts `wg_public` before JWT middleware at `/wg-public`
- frontend has a dedicated route branch for `/wg-public`
## 7. How the frontend is organized
Entry path:
- `frontend/src/main.tsx` -> `frontend/src/App.tsx` -> `frontend/src/Router.tsx`
Frontend routing model:
- `Router.tsx` checks health/setup/auth before rendering the main app
- `/wg-public/*` bypasses the standard authenticated admin shell
- most admin pages are lazy-loaded from `frontend/src/pages/*`
Useful places by task:
- New screen or route: `frontend/src/Router.tsx` and `frontend/src/pages/`
- Backend API bindings: `frontend/src/api/`
- Server state/query logic: `frontend/src/hooks/`
- Shared app state: `frontend/src/context/`
- Reusable UI: `frontend/src/components/`
- Modal workflows: `frontend/src/modals/`
## 8. Build, test, lint commands
This repo is multi-package. There is no obvious root workspace command.
Run commands in the relevant subdirectory.
### Backend
Working directory: `backend/`
- `yarn lint`
- `yarn prettier`
- `yarn validate-schema`
- `yarn regenerate-config`
- likely local start: `node index.js`
### Frontend
Working directory: `frontend/`
- `yarn dev`
- `yarn build`
- `yarn lint`
- `yarn preview`
- `yarn test`
- `yarn locale-extract`
- `yarn locale-compile`
- `yarn locale-sort`
### Docs
Working directory: `docs/`
- `yarn dev`
- `yarn build`
- `yarn preview`
### Test
Working directory: `test/`
- `yarn cypress`
- `yarn cypress:headless`
- `yarn cypress:dev`
- `yarn swagger-lint`
### Full build
The production Docker build expects the frontend to be built first.
Common flow:
1. build frontend in `frontend/`
2. build image with `docker/Dockerfile`
Helper script:
- `scripts/build-project.sh`
## 9. Key technologies
- Backend: Node.js, Express 5, Knex, Objection, AJV
- Databases: SQLite by default, MySQL/Postgres supported
- Frontend: React, TypeScript, Vite, React Router, TanStack Query, Tabler UI
- Testing: Vitest for frontend, Cypress for E2E
- Packaging/runtime: Docker, nginx, s6 overlay
- System integration: WireGuard CLI tools inside the container/runtime
## 10. Configuration model
The backend resolves configuration in this order:
1. JSON config file from `NODE_CONFIG_DIR` / `config/*.json`
2. environment variables
3. SQLite fallback using `/data/database.sqlite`
Important file:
- `backend/lib/config.js`
Notes:
- JWT keys are persisted to `/data/keys.json`
- default DB fallback is SQLite
- production behavior often depends on mounted volumes and env vars, not just source code
## 11. WireGuard-specific map
When the task is specifically about WireGuard, start here:
- `backend/internal/wireguard.js`
- `backend/routes/wireguard.js`
- `backend/routes/wg_public.js`
- `frontend/src/pages/WireGuard/`
- `frontend/src/api/backend/wireguard.ts`
- `frontend/src/hooks/useWireGuard.ts`
- relevant migration files in `backend/migrations/`
Operational assumptions:
- `WG_ENABLED` controls whether WireGuard startup hooks run
- runtime requires container/network capabilities such as `NET_ADMIN`
- some behavior only makes sense inside the Dockerized runtime
## 12. Production packaging model
Production image definition:
- `docker/Dockerfile`
Important facts:
- it copies `backend/` into `/app`
- it copies `frontend/dist` into `/app/frontend`
- it installs runtime dependencies inside the image
- it relies on s6-overlay and rootfs scripts
This means:
- frontend must be built before image build
- Docker/runtime files are part of app behavior, not just deployment plumbing
## 13. Known gotchas
- `docker/Dockerfile` assumes `frontend/dist` already exists
- several scripts are shell-oriented and may be awkward on native Windows
- docs and names are inherited from upstream in places, so naming is mixed:
`nginx-proxy-manager`, `xGat3`, `D3V-Server`, `D3V-NPMWG`
- `README.md` currently has encoding artifacts; do not assume every displayed character is trustworthy
- backend package has no clear `start` script, so use `backend/index.js` as the canonical runtime entry
- do not treat this as a simple SPA-only repo or API-only repo; the Docker assembly matters
## 14. Change strategy for AI agents
When handling a task:
1. Read this file
2. Open only the subsystem entry files for the relevant area
3. Avoid broad repo scans unless the task is cross-cutting
4. Verify whether the change affects Docker/runtime assumptions
5. If the task touches WireGuard, include container/runtime implications in reasoning
Open only what you need:
- backend bug: `backend/index.js`, `backend/app.js`, matching route/service/model files
- frontend bug: `frontend/src/main.tsx`, `App.tsx`, `Router.tsx`, matching page/hook/api files
- build/deploy bug: `docker/Dockerfile`, `scripts/build-project.sh`, `install.sh`
- auth/setup issue: `backend/setup.js`, `backend/routes/tokens.js`, `frontend/src/context/`
## 15. Fast summary
If you only remember one thing:
`D3V-Server` is a Docker-assembled full-stack app where:
- backend runs the API and system logic
- frontend is a Vite React admin UI
- WireGuard is an integrated subsystem, not a separate service
- Docker/runtime files are part of the product behavior
- `backend/index.js`, `backend/app.js`, `backend/routes/main.js`,
`frontend/src/main.tsx`, `frontend/src/App.tsx`,
`frontend/src/Router.tsx`, and `docker/Dockerfile` are the core orientation files

195
README.md
View file

@ -1,71 +1,75 @@
# D3V-NPMWG — Nginx Proxy Manager + WireGuard VPN
# D3V Gateway — Reverse Proxy + WireGuard VPN
A powerful, all-in-one Docker container that combines **Nginx Proxy Manager** (reverse proxy with SSL) and **WireGuard VPN** management in a single, beautiful web interface.
A lightweight all-in-one Docker deployment that combines reverse proxy management with SSL and **WireGuard VPN** in a single web interface.
## Features
## Features
### Nginx Proxy Manager
- 🌐 Reverse proxy management with a beautiful UI
- 🔒 Free SSL certificates via Let's Encrypt
- 🔀 Proxy hosts, redirection hosts, streams, and 404 hosts
- 🛡️ Access control lists
- 📊 Audit logging
### Gateway
- Reverse proxy management with SSL (Let's Encrypt)
- Proxy hosts, redirection hosts, streams, and 404 hosts
- Access control lists and audit logging
### WireGuard VPN Manager
- 🔑 Create, enable, disable, and delete VPN clients
- 📱 QR code generation for mobile clients
- 📥 Download `.conf` configuration files
- 📡 Real-time client status (connected, idle, data transfer)
- ⏰ Client expiration support
- 🔄 Auto-sync WireGuard configs
- Create, enable, disable, and delete VPN clients
- QR code generation for mobile clients
- Download `.conf` configuration files
- Real-time client status (connected, idle, data transfer)
- Client expiration support
- Auto-sync WireGuard configs
- Client isolation (block inter-client traffic)
- Encrypted per-client file storage
## 🚀 Quick Start (Auto Install)
### Forgejo Integration (optional)
- Self-hosted Git server on the same VPS
- Accessible only via domain through NPM proxy
- CI/CD via Forgejo Runner for automated Docker builds
The easiest way to install, update, and manage your D3V-NPMWG instance on Linux is by using our interactive manager script.
### Blog Starter
- `blog-starter/` contains a ready-to-use Hugo + LoveIt starter
- includes a Forgejo Actions workflow that deploys generated files to `/opt/blog/public`
- intended to be used as the base of a separate blog repository
- `install.sh blog-deploy-info` prints the deploy user and the secret values needed by Forgejo Actions
---
## Quick Start
```bash
# Download and run the install script
curl -sSL https://raw.githubusercontent.com/xtcnet/D3V-NPMWG/master/install.sh -o install.sh
curl -sSL https://src.d3v.ac/d3v/D3V-Server/raw/branch/master/install.sh -o install.sh
chmod +x install.sh
sudo ./install.sh
```
**Features included in the script:**
- `Install D3V-NPMWG`: Automatically setup docker-compose and directories in `/opt/d3v-npmwg`.
- `Uninstall D3V-NPMWG`: Remove containers and wipe data.
- `Reset Password`: Resets the admin login to `admin@example.com` / `changeme`.
- `Update`: Pulls the latest image and updates the docker-compose stack.
**Main menu:**
- `1` Gateway
- `2` Blog
- `3` Forgejo
- `4` Status / Logs / Health Check
- `5` Exit
You can also run specific commands directly: `sudo ./install.sh {install|uninstall|reset|update}`
**Gateway submenu:**
- `1` Install Gateway
- `2` Uninstall Gateway
- `3` Uninstall Gateway + Docker (Purge)
- `4` Reset Admin Password
- `5` Update Gateway
- `6` Manage Custom Stream Ports
- `7` Toggle Admin Port 81 (Block/Unblock)
You can also run commands directly:
```bash
sudo ./install.sh {gateway|gateway-install|gateway-uninstall|gateway-purge|gateway-reset|gateway-update|manage-ports|toggle-port|blog|blog-install|blog-update|blog-uninstall|forgejo|runner-update|ops}
```
---
## 🐋 Manual Docker Run```bash
docker run -d \
--name npm-wg \
--cap-add=NET_ADMIN \
--cap-add=SYS_MODULE \
--sysctl net.ipv4.ip_forward=1 \
--sysctl net.ipv4.conf.all.src_valid_mark=1 \
-p 80:80 \
-p 81:81 \
-p 443:443 \
-p 51820:51820/udp \
-v npm-wg-data:/data \
-v npm-wg-letsencrypt:/etc/letsencrypt \
-v npm-wg-wireguard:/etc/wireguard \
-e WG_HOST=your.server.ip \
npm-wg:latest
```
## 📋 Docker Compose
## Manual Docker Compose
```yaml
version: "3.8"
services:
npm-wg:
image: npm-wg:latest
container_name: npm-wg
d3v-npmwg:
image: src.d3v.ac/d3v/d3v-server:latest
container_name: d3v-npmwg
restart: unless-stopped
cap_add:
- NET_ADMIN
@ -74,38 +78,26 @@ services:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
ports:
- "80:80" # HTTP
- "81:81" # Admin UI
- "443:443" # HTTPS
- "51820:51820/udp" # WireGuard
- "80:80"
- "81:81"
- "443:443"
- "51820-51830:51820-51830/udp"
volumes:
- data:/data
- letsencrypt:/etc/letsencrypt
- wireguard:/etc/wireguard
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
- ./wireguard:/etc/wireguard
environment:
WG_HOST: "your.server.ip" # REQUIRED: Your server's public IP or domain
# WG_PORT: 51820 # WireGuard listen port
# WG_DEFAULT_ADDRESS: 10.8.0.0/24 # VPN subnet
# WG_DNS: 1.1.1.1,8.8.8.8 # DNS for VPN clients
# WG_MTU: 1420 # MTU for VPN
# WG_ALLOWED_IPS: 0.0.0.0/0,::/0 # Allowed IPs for clients
# WG_PERSISTENT_KEEPALIVE: 25
# WG_ENABLED: true # Set to false to disable WireGuard
volumes:
data:
letsencrypt:
wireguard:
WG_HOST: "your.server.ip" # REQUIRED
```
## 🔧 Environment Variables
---
### WireGuard Settings
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `WG_ENABLED` | `true` | Enable/disable WireGuard VPN |
| `WG_HOST` | *(required)* | Public IP or domain of your server |
| `WG_ENABLED` | `true` | Enable/disable WireGuard |
| `WG_PORT` | `51820` | WireGuard UDP listen port |
| `WG_DEFAULT_ADDRESS` | `10.8.0.0/24` | VPN subnet CIDR |
| `WG_DNS` | `1.1.1.1, 8.8.8.8` | DNS servers for VPN clients |
@ -113,66 +105,45 @@ volumes:
| `WG_ALLOWED_IPS` | `0.0.0.0/0, ::/0` | Default allowed IPs for clients |
| `WG_PERSISTENT_KEEPALIVE` | `25` | Keepalive interval in seconds |
## 🌍 Ports
## Ports
| Port | Protocol | Description |
|------|----------|-------------|
| `80` | TCP | HTTP |
| `81` | TCP | Admin Web UI |
| `443` | TCP | HTTPS |
| `51820` | UDP | WireGuard VPN |
| `5182051830` | UDP | WireGuard Multi-Server |
## 📖 Usage
---
1. **Access the Admin UI** at `http://your-server:81`
2. **Set up NPM** with your admin email and password
3. **Navigate to WireGuard** from the sidebar menu
4. **Create VPN clients** by clicking "New Client"
5. **Scan QR code** or **download .conf** file to configure WireGuard on your devices
## 🏗️ Building and CI/CD
### ☁️ Automated Build (Docker Cloud Build)
This project is configured with **GitHub Actions** (`.github/workflows/docker-publish.yml`) to automatically build and push multi-arch Docker images (`amd64`, `arm64`) to **GitHub Container Registry (GHCR)** whenever a push is made to the `master` branch or a version tag is created.
Images are available at: `ghcr.io/xtcnet/d3v-npmwg:latest`
### 🏗️ Building from Source Local
To build D3V-NPMWG from source manually, you must build the React frontend before building the Docker image:
## Building from Source
```bash
# Clone the repository
git clone https://github.com/xtcnet/D3V-NPMWG.git
cd D3V-NPMWG
git clone https://src.d3v.ac/d3v/D3V-Server.git
cd D3V-Server
# 1. Build the Frontend
cd frontend
yarn install
yarn build
cd ..
# 2. Build the Docker Image
# IMPORTANT: Do not forget the trailing dot '.' at the end of the command!
docker build -t npm-wg -f docker/Dockerfile .
cd frontend && yarn install && yarn locale-compile && yarn build && cd ..
docker build -t d3v-gateway -f docker/Dockerfile .
```
Alternatively, you can run the helper script:
```bash
./scripts/build-project.sh
```
### CI/CD
## ⚠️ Requirements
Pushes to `master` that touch `backend/`, `frontend/`, or `docker/` automatically build and push the Docker image to `src.d3v.ac/d3v/d3v-server:latest` via Forgejo Actions.
- **Docker** with Linux containers
- **Host kernel** must support WireGuard (Linux 5.6+ or WireGuard kernel module)
- Container requires `NET_ADMIN` and `SYS_MODULE` capabilities
- IP forwarding must be enabled (`net.ipv4.ip_forward=1`)
---
## 📜 Credits
## Requirements
- Docker with Linux containers
- Host kernel with WireGuard support (Linux 5.6+)
- `NET_ADMIN` and `SYS_MODULE` capabilities
- `net.ipv4.ip_forward=1`
## Credits
- [Nginx Proxy Manager](https://github.com/NginxProxyManager/nginx-proxy-manager) — Original proxy manager
- [wg-easy](https://github.com/wg-easy/wg-easy) — WireGuard management inspiration
## 📄 License
## License
MIT License

View file

@ -2,11 +2,14 @@ import bodyParser from "body-parser";
import compression from "compression";
import express from "express";
import fileUpload from "express-fileupload";
import helmet from "helmet";
import { rateLimit } from "express-rate-limit";
import { isDebugMode } from "./lib/config.js";
import cors from "./lib/express/cors.js";
import jwt from "./lib/express/jwt.js";
import { debug, express as logger } from "./logger.js";
import mainRoutes from "./routes/main.js";
import wgPublicRoutes from "./routes/wg_public.js";
/**
* App
@ -35,25 +38,54 @@ if (isDebugMode()) {
// CORS for everything
app.use(cors);
// General security/cache related headers + server header
app.use((_, res, next) => {
let x_frame_options = "DENY";
if (typeof process.env.X_FRAME_OPTIONS !== "undefined" && process.env.X_FRAME_OPTIONS) {
x_frame_options = process.env.X_FRAME_OPTIONS;
}
res.set({
"X-XSS-Protection": "1; mode=block",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": x_frame_options,
"Cache-Control": "no-cache, no-store, max-age=0, must-revalidate",
Pragma: "no-cache",
Expires: 0,
});
next();
/**
* Global Rate Limiter: 100 requests per minute per IP
*/
const globalLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: { message: "Too many requests, please try again later." } },
});
/**
* Login Rate Limiter: 10 requests per 15 minutes per IP
*/
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: { message: "Too many login attempts, please try again in 15 minutes." } },
});
/**
* Helmet Security Headers
*/
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // unsafe-inline/eval required by some React libs
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https://www.gravatar.com"],
connectSrc: ["'self'"],
frameAncestors: [process.env.X_FRAME_OPTIONS === "ALLOWALL" ? "*" : "'none'"],
},
},
crossOriginResourcePolicy: { policy: "cross-origin" },
}),
);
// Apply rate limiting
app.use("/api/tokens", loginLimiter);
app.use("/api/", globalLimiter);
// Bypass JWT for public authenticated requests mapped by WireGuard IP
app.use("/wg-public", wgPublicRoutes);
app.use(jwt());
app.use("/", mainRoutes);

View file

@ -6,7 +6,7 @@ let instance = null;
const generateDbConfig = () => {
if (!configHas("database")) {
throw new Error(
"Database config does not exist! Please read the instructions: https://nginxproxymanager.com/setup/",
"Database config does not exist! Please read the instructions: https://x.d3v.ac/setup/",
);
}

View file

@ -5,7 +5,7 @@ import errs from "../lib/error.js";
import authModel from "../models/auth.js";
import internalUser from "./user.js";
const APP_NAME = "Nginx Proxy Manager";
const APP_NAME = "xGat3";
const BACKUP_CODE_COUNT = 8;
/**

View file

@ -17,6 +17,7 @@ const internalAuditLog = {
const query = auditLogModel
.query()
.andWhere("user_id", access.token.getUserId(1))
.orderBy("created_on", "DESC")
.orderBy("id", "DESC")
.limit(100)
@ -49,6 +50,7 @@ const internalAuditLog = {
const query = auditLogModel
.query()
.andWhere("id", data.id)
.andWhere("user_id", access.token.getUserId(1))
.allowGraph("[user]")
.first();

View file

@ -0,0 +1,66 @@
import db from "../db.js";
import { debug, express as logger } from "../logger.js";
const internalDatabase = {
/**
* Get all tables in the database (SQLite specific, but Knex supports raw queries for others too)
*/
async getTables() {
const knex = db();
// Attempt SQLite first, fallback to generic if using mysql/mariadb
try {
// For SQLite
const tables = await knex.raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'");
return tables.map(t => t.name).sort();
} catch (e) {
// For MySQL/MariaDB
const tables = await knex.raw("SHOW TABLES");
return tables[0].map(t => Object.values(t)[0]).sort();
}
},
/**
* Get table schema and paginated rows
*/
async getTableData(tableName, limit = 50, offset = 0) {
const knex = db();
// 1. Get Schema/PRAGMA
let schema = [];
try {
const info = await knex.raw(`PRAGMA table_info("${tableName}")`);
schema = info; // SQLite structure
} catch (e) {
// MySQL fallback
const info = await knex.raw(`DESCRIBE \`${tableName}\``);
schema = info[0];
}
// 2. Count total rows
const countResult = await knex(tableName).count("id as count").first();
const total = parseInt(countResult.count || 0, 10);
// 3. Get rows
const rows = await knex(tableName)
.select("*")
.limit(limit)
.offset(offset)
// Try ordering by ID or created_on if possible, fallback to whatever db returns
.orderBy(
schema.find(col => col.name === 'created_on' || col.Field === 'created_on') ? 'created_on' :
(schema.find(col => col.name === 'id' || col.Field === 'id') ? 'id' : undefined) || '1',
'desc'
)
.catch(() => knex(tableName).select("*").limit(limit).offset(offset)); // If order fails
return {
name: tableName,
schema,
total,
rows
};
}
};
export default internalDatabase;

View file

@ -1,3 +1,4 @@
import db from "../db.js";
import internalDeadHost from "./dead-host.js";
import internalProxyHost from "./proxy-host.js";
import internalRedirectionHost from "./redirection-host.js";
@ -23,15 +24,29 @@ const internalReport = {
return Promise.all(promises);
})
.then((counts) => {
.then(async (counts) => {
const knex = db();
let wgServers = 0;
let wgClients = 0;
try {
const srvResult = await knex("wg_interface").count("id as count").first();
wgServers = srvResult?.count || 0;
const cliResult = await knex("wg_client").count("id as count").first();
wgClients = cliResult?.count || 0;
} catch (_) {
// WireGuard tables may not exist yet
}
return {
proxy: counts.shift(),
redirection: counts.shift(),
stream: counts.shift(),
dead: counts.shift(),
wgServers: Number(wgServers),
wgClients: Number(wgClients),
};
});
},
};
export default internalReport;

View file

@ -0,0 +1,192 @@
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { debug, express as logger } from "../logger.js";
const WG_FILES_DIR = process.env.WG_FILES_DIR || "/data/wg_clients";
// Ensure root dir exists
if (!fs.existsSync(WG_FILES_DIR)) {
fs.mkdirSync(WG_FILES_DIR, { recursive: true });
}
export default {
/**
* Derive a 32-byte AES-256 key from the client's private key
*/
getKey(privateKey) {
return crypto.createHash("sha256").update(privateKey).digest();
},
getClientDir(ipv4Address) {
// Clean the IP address to prevent traversal
const safeIp = ipv4Address.replace(/[^0-9.]/g, "");
const dirPath = path.join(WG_FILES_DIR, safeIp);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
return dirPath;
},
/**
* Destroys a client's entire isolated file directory and all encrypted contents
*/
async deleteClientDir(ipv4Address) {
const safeIp = ipv4Address.replace(/[^0-9.]/g, "");
const dirPath = path.join(WG_FILES_DIR, safeIp);
if (fs.existsSync(dirPath)) {
await fs.promises.rm(dirPath, { recursive: true, force: true });
}
},
/**
* Scans a client partition and returns the total byte size utilized
*/
async getClientStorageUsage(ipv4Address) {
const dir = this.getClientDir(ipv4Address);
try {
const files = await fs.promises.readdir(dir);
let totalBytes = 0;
for (const file of files) {
const filePath = path.join(dir, file);
const stats = await fs.promises.stat(filePath);
if (stats.isFile()) {
totalBytes += stats.size;
}
}
return totalBytes;
} catch (err) {
return 0;
}
},
/**
* List all files in a client's isolated directory
*/
async listFiles(ipv4Address) {
const dir = this.getClientDir(ipv4Address);
const files = await fs.promises.readdir(dir);
const result = [];
for (const file of files) {
const filePath = path.join(dir, file);
const stats = await fs.promises.stat(filePath);
if (stats.isFile()) {
result.push({
name: file,
size: stats.size, // Note: Encrypted size includes 16 byte IV + pad
created: stats.birthtime,
modified: stats.mtime
});
}
}
return result;
},
/**
* Encrypt and save a file buffer to disk
*/
async uploadFile(ipv4Address, privateKey, filename, fileBuffer) {
const dir = this.getClientDir(ipv4Address);
// Prevent path traversal
const safeFilename = path.basename(filename);
const filePath = path.join(dir, safeFilename);
const key = this.getKey(privateKey);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
return new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(filePath);
writeStream.on("error", (err) => reject(err));
writeStream.on("finish", () => resolve({ success: true, name: safeFilename }));
// Write the 16-byte IV first
writeStream.write(iv);
// Pipe the cipher output to the file
cipher.pipe(writeStream);
// Write the actual file buffer into the cipher
cipher.write(fileBuffer);
cipher.end();
});
},
/**
* Decrypt a file and pipe it to standard response stream
*/
async downloadFile(ipv4Address, privateKey, filename, res) {
const dir = this.getClientDir(ipv4Address);
const safeFilename = path.basename(filename);
const filePath = path.join(dir, safeFilename);
if (!fs.existsSync(filePath)) {
throw new Error("File not found");
}
const key = this.getKey(privateKey);
const fileDescriptor = await fs.promises.open(filePath, "r");
// Read first 16 bytes to extract IV
const ivBuffer = Buffer.alloc(16);
await fileDescriptor.read(ivBuffer, 0, 16, 0);
await fileDescriptor.close();
// Create a read stream starting AFTER the 16 byte IV
const readStream = fs.createReadStream(filePath, { start: 16 });
const decipher = crypto.createDecipheriv("aes-256-cbc", key, ivBuffer);
// Set response headers for download
res.setHeader("Content-Disposition", `attachment; filename="${safeFilename}"`);
res.setHeader("Content-Type", "application/octet-stream");
// Catch error in pipeline without crashing the root process
readStream.on("error", (err) => {
logger.error(`Error reading encrypted file ${safeFilename}: ${err.message}`);
if (!res.headersSent) res.status(500).end();
});
decipher.on("error", (err) => {
logger.error(`Error decrypting file ${safeFilename}: ${err.message}`);
if (!res.headersSent) res.status(500).end();
});
readStream.pipe(decipher).pipe(res);
},
/**
* Delete an encrypted file
*/
async deleteFile(ipv4Address, filename) {
const dir = this.getClientDir(ipv4Address);
const safeFilename = path.basename(filename);
const filePath = path.join(dir, safeFilename);
if (fs.existsSync(filePath)) {
await fs.promises.unlink(filePath);
}
return { success: true };
},
/**
* Rename an encrypted file (no re-encryption needed, just fs.rename)
*/
async renameFile(ipv4Address, oldName, newName) {
const dir = this.getClientDir(ipv4Address);
const safeOld = path.basename(oldName);
const safeNew = path.basename(newName);
const oldPath = path.join(dir, safeOld);
const newPath = path.join(dir, safeNew);
if (!fs.existsSync(oldPath)) {
throw new Error("File not found");
}
if (fs.existsSync(newPath)) {
throw new Error("File name already exists");
}
await fs.promises.rename(oldPath, newPath);
return { success: true };
}
};

View file

@ -1,6 +1,13 @@
import fs from "fs";
import { exec } from "child_process";
import { promisify } from "util";
import { global as logger } from "../logger.js";
import * as wgHelpers from "../lib/wg-helpers.js";
import internalWireguardFs from "./wireguard-fs.js";
import internalAuditLog from "./audit-log.js";
import { encrypt, decrypt } from "../lib/crypto.js";
const execAsync = promisify(exec);
const WG_INTERFACE_NAME = process.env.WG_INTERFACE_NAME || "wg0";
const WG_DEFAULT_PORT = Number.parseInt(process.env.WG_PORT || "51820", 10);
@ -13,6 +20,7 @@ const WG_DEFAULT_PERSISTENT_KEEPALIVE = Number.parseInt(process.env.WG_PERSISTEN
const WG_CONFIG_DIR = "/etc/wireguard";
let cronTimer = null;
let connectionMemoryMap = {};
const internalWireguard = {
@ -22,112 +30,180 @@ const internalWireguard = {
async getOrCreateInterface(knex) {
let iface = await knex("wg_interface").first();
if (!iface) {
// Generate keys
const privateKey = await wgHelpers.generatePrivateKey();
const publicKey = await wgHelpers.getPublicKey(privateKey);
const [id] = await knex("wg_interface").insert({
name: WG_INTERFACE_NAME,
private_key: privateKey,
// Seed a default config if it doesn't exist
const insertData = {
name: "wg0",
private_key: encrypt(privateKey),
public_key: publicKey,
ipv4_cidr: WG_DEFAULT_ADDRESS,
listen_port: WG_DEFAULT_PORT,
mtu: WG_DEFAULT_MTU,
listen_port: 51820,
ipv4_cidr: "10.0.0.1/24",
mtu: 1420,
dns: WG_DEFAULT_DNS,
host: WG_HOST,
post_up: `iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE`,
post_down: `iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE`,
post_up: "iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE",
post_down: "iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE",
created_on: knex.fn.now(),
modified_on: knex.fn.now(),
});
};
const [id] = await knex("wg_interface").insert(insertData);
iface = await knex("wg_interface").where("id", id).first();
logger.info("WireGuard interface created with new keypair");
logger.info("WireGuard interface created with default config");
}
return iface;
},
/**
* Save WireGuard config to /etc/wireguard/wg0.conf and sync
* Render PostUp and PostDown iptables rules based on interface, isolation, and links
*/
async saveConfig(knex) {
const iface = await this.getOrCreateInterface(knex);
const clients = await knex("wg_client").where("enabled", true);
async renderIptablesRules(knex, iface) {
const basePostUp = [];
const basePostDown = [];
// Generate server interface section
const parsed = wgHelpers.parseCIDR(iface.ipv4_cidr);
const serverAddress = `${parsed.firstHost}/${parsed.prefix}`;
// Default forward and NAT
basePostUp.push("iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE");
basePostDown.push("iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE");
let configContent = wgHelpers.generateServerInterface({
privateKey: iface.private_key,
address: serverAddress,
listenPort: iface.listen_port,
mtu: iface.mtu,
dns: null, // DNS is for clients, not server
postUp: iface.post_up,
postDown: iface.post_down,
});
// Server Isolation: drop cross-server traffic by default.
// Uses -I to insert at position 1, before the ACCEPT rules above.
basePostUp.push("iptables -I FORWARD -i %i -o wg+ -j DROP");
basePostDown.push("iptables -D FORWARD -i %i -o wg+ -j DROP");
// Generate peer sections for each enabled client
for (const client of clients) {
configContent += "\n\n" + wgHelpers.generateServerPeer({
publicKey: client.public_key,
preSharedKey: client.pre_shared_key,
allowedIps: `${client.ipv4_address}/32`,
});
// Same-interface rule: inserted AFTER the DROP above so it lands at position 1,
// placing it BEFORE the wg+ DROP in the chain.
// This ensures client-to-client traffic on this interface is evaluated first.
if (iface.isolate_clients) {
basePostUp.push("iptables -I FORWARD -i %i -o %i -j REJECT");
basePostDown.push("iptables -D FORWARD -i %i -o %i -j REJECT");
} else {
basePostUp.push("iptables -I FORWARD -i %i -o %i -j ACCEPT");
basePostDown.push("iptables -D FORWARD -i %i -o %i -j ACCEPT");
}
configContent += "\n";
// 2. Fetch linked servers to punch holes in the DROP rule
// wg_server_link has interface_id_1 and interface_id_2
const links = await knex("wg_server_link")
.where("interface_id_1", iface.id)
.orWhere("interface_id_2", iface.id);
// Write config file
const configPath = `${WG_CONFIG_DIR}/${iface.name}.conf`;
fs.writeFileSync(configPath, configContent, { mode: 0o600 });
logger.info(`WireGuard config saved to ${configPath}`);
for (const link of links) {
const peerIfaceId = link.interface_id_1 === iface.id ? link.interface_id_2 : link.interface_id_1;
const peerIface = await knex("wg_interface").where("id", peerIfaceId).first();
if (peerIface) {
basePostUp.push(`iptables -I FORWARD -i %i -o ${peerIface.name} -j ACCEPT`);
basePostDown.push(`iptables -D FORWARD -i %i -o ${peerIface.name} -j ACCEPT`);
}
}
// Sync config
try {
await wgHelpers.wgSync(iface.name);
logger.info("WireGuard config synced");
} catch (err) {
logger.warn("WireGuard sync failed, may need full restart:", err.message);
return {
postUp: basePostUp.join("; "),
postDown: basePostDown.join("; "),
};
},
/**
* Save WireGuard config to /etc/wireguard/wgX.conf and sync
*/
async saveConfig(knex) {
await this.getOrCreateInterface(knex); // Ensure at least wg0 exists
const ifaces = await knex("wg_interface").select("*");
const clients = await knex("wg_client").where("enabled", true);
for (const iface of ifaces) {
// 1. Render IPTables Rules dynamically for this interface
const { postUp, postDown } = await this.renderIptablesRules(knex, iface);
// 2. Generate server interface section
const parsed = wgHelpers.parseCIDR(iface.ipv4_cidr);
const serverAddress = `${parsed.firstHost}/${parsed.prefix}`;
let configContent = wgHelpers.generateServerInterface({
privateKey: decrypt(iface.private_key),
address: serverAddress,
listenPort: iface.listen_port,
mtu: iface.mtu,
dns: null, // DNS is for clients, not server
postUp: postUp,
postDown: postDown,
});
// 3. Generate peer sections for each enabled client ON THIS SERVER
const ifaceClients = clients.filter(c => c.interface_id === iface.id);
for (const client of ifaceClients) {
configContent += "\n\n" + wgHelpers.generateServerPeer({
publicKey: client.public_key,
preSharedKey: decrypt(client.pre_shared_key),
allowedIps: `${client.ipv4_address}/32`,
});
}
configContent += "\n";
// 4. Write config file
const configPath = `${WG_CONFIG_DIR}/${iface.name}.conf`;
fs.writeFileSync(configPath, configContent, { mode: 0o600 });
logger.info(`WireGuard config saved to ${configPath}`);
// 5. Sync config
try {
await wgHelpers.wgSync(iface.name);
logger.info(`WireGuard config synced for ${iface.name}`);
// 6. Apply iptables isolation rule directly (wg syncconf does not run PostUp/PostDown)
await this.syncIptablesRules(iface);
// 7. Apply traffic control bandwidth partitions non-blocking
this.applyBandwidthLimits(knex, iface).catch((e) => logger.warn(`Skipping QoS on ${iface.name}: ${e.message}`));
} catch (err) {
logger.warn(`WireGuard sync failed for ${iface.name}, may need full restart:`, err.message);
}
}
},
/**
* Start WireGuard interface
* Start WireGuard interfaces
*/
async startup(knex) {
try {
const iface = await this.getOrCreateInterface(knex);
await this.getOrCreateInterface(knex); // ensure at least wg0
// Ensure config dir exists
if (!fs.existsSync(WG_CONFIG_DIR)) {
fs.mkdirSync(WG_CONFIG_DIR, { recursive: true });
}
// Save config first
// Save configs first (generates .conf files dynamically for all wg_interfaces)
await this.saveConfig(knex);
// Bring down if already up, then up
try {
await wgHelpers.wgDown(iface.name);
} catch (_) {
// Ignore if not up
}
// Bring down/up all interfaces sequentially
const ifaces = await knex("wg_interface").select("name", "listen_port");
for (const iface of ifaces) {
try {
await wgHelpers.wgDown(iface.name);
} catch (_) {
// Ignore if not up
}
await wgHelpers.wgUp(iface.name);
logger.info(`WireGuard interface ${iface.name} started on port ${iface.listen_port}`);
try {
await wgHelpers.wgUp(iface.name);
logger.info(`WireGuard interface ${iface.name} started on port ${iface.listen_port}`);
} catch (err) {
logger.error(`WireGuard startup failed for ${iface.name}:`, err.message);
}
}
// Start cron job for expiration
this.startCronJob(knex);
} catch (err) {
logger.error("WireGuard startup failed:", err.message);
logger.warn("WireGuard features will be unavailable. Ensure the host supports WireGuard kernel module.");
logger.error("WireGuard startup failed overall:", err.message);
}
},
/**
* Shutdown WireGuard interface
* Shutdown WireGuard interfaces
*/
async shutdown(knex) {
if (cronTimer) {
@ -135,26 +211,42 @@ const internalWireguard = {
cronTimer = null;
}
try {
const iface = await knex("wg_interface").first();
if (iface) {
await wgHelpers.wgDown(iface.name);
logger.info(`WireGuard interface ${iface.name} stopped`);
const ifaces = await knex("wg_interface").select("name");
for (const iface of ifaces) {
try {
await wgHelpers.wgDown(iface.name);
logger.info(`WireGuard interface ${iface.name} stopped`);
} catch (err) {
logger.warn(`WireGuard shutdown warning for ${iface.name}:`, err.message);
}
}
} catch (err) {
logger.warn("WireGuard shutdown warning:", err.message);
logger.error("WireGuard shutdown failed querying DB:", err.message);
}
},
/**
* Get all clients with live status
* Get all clients with live status and interface name correlation
*/
async getClients(knex) {
const iface = await this.getOrCreateInterface(knex);
const dbClients = await knex("wg_client").orderBy("created_on", "desc");
async getClients(knex, access, accessData) {
await this.getOrCreateInterface(knex); // Ensure structure exists
const query = knex("wg_client")
.join("wg_interface", "wg_client.interface_id", "=", "wg_interface.id")
.select("wg_client.*", "wg_interface.name as interface_name")
.orderBy("wg_client.created_on", "desc");
if (access) {
query.andWhere("wg_client.owner_user_id", access.token.getUserId(1));
}
const dbClients = await query;
const clients = dbClients.map((c) => ({
id: c.id,
name: c.name,
interfaceName: c.interface_name,
interfaceId: c.interface_id,
enabled: c.enabled === 1 || c.enabled === true,
ipv4_address: c.ipv4_address,
public_key: c.public_key,
@ -170,20 +262,28 @@ const internalWireguard = {
transfer_tx: 0,
}));
// Get live WireGuard status
try {
const dump = await wgHelpers.wgDump(iface.name);
for (const peer of dump) {
const client = clients.find((c) => c.public_key === peer.publicKey);
if (client) {
client.latest_handshake_at = peer.latestHandshakeAt;
client.endpoint = peer.endpoint;
client.transfer_rx = peer.transferRx;
client.transfer_tx = peer.transferTx;
// Get live WireGuard status from ALL interfaces
const ifaces = await knex("wg_interface").select("name");
for (const iface of ifaces) {
try {
const dump = await wgHelpers.wgDump(iface.name);
for (const peer of dump) {
const client = clients.find((c) => c.public_key === peer.publicKey);
if (client) {
client.latest_handshake_at = peer.latestHandshakeAt;
client.endpoint = peer.endpoint;
client.transfer_rx = peer.transferRx;
client.transfer_tx = peer.transferTx;
}
}
} catch (_) {
// WireGuard might be off or particular interface fails
}
} catch (_) {
// WireGuard may not be running
}
// Inject Storage Utilization Metrics
for (const client of clients) {
client.storage_usage_bytes = await internalWireguardFs.getClientStorageUsage(client.ipv4_address);
}
return clients;
@ -192,8 +292,10 @@ const internalWireguard = {
/**
* Create a new WireGuard client
*/
async createClient(knex, data) {
const iface = await this.getOrCreateInterface(knex);
async createClient(knex, data, access, accessData) {
const iface = data.interface_id
? await knex("wg_interface").where("id", data.interface_id).first()
: await this.getOrCreateInterface(knex);
// Generate keys
const privateKey = await wgHelpers.generatePrivateKey();
@ -201,20 +303,29 @@ const internalWireguard = {
const preSharedKey = await wgHelpers.generatePreSharedKey();
// Allocate IP
const existingClients = await knex("wg_client").select("ipv4_address");
const existingClients = await knex("wg_client").select("ipv4_address").where("interface_id", iface.id);
const allocatedIPs = existingClients.map((c) => c.ipv4_address);
const ipv4Address = wgHelpers.findNextAvailableIP(iface.ipv4_cidr, allocatedIPs);
if (!ipv4Address) {
throw new Error("No available IP addresses remaining in this WireGuard server subnet.");
}
// Scrub any old junk partitions to prevent leakage
await internalWireguardFs.deleteClientDir(ipv4Address);
const clientData = {
name: data.name || "Unnamed Client",
enabled: true,
ipv4_address: ipv4Address,
private_key: privateKey,
private_key: encrypt(privateKey),
public_key: publicKey,
pre_shared_key: preSharedKey,
pre_shared_key: encrypt(preSharedKey),
allowed_ips: data.allowed_ips || WG_DEFAULT_ALLOWED_IPS,
persistent_keepalive: data.persistent_keepalive || WG_DEFAULT_PERSISTENT_KEEPALIVE,
expires_at: data.expires_at || null,
interface_id: iface.id,
owner_user_id: access ? access.token.getUserId(1) : 1,
created_on: knex.fn.now(),
modified_on: knex.fn.now(),
};
@ -230,13 +341,21 @@ const internalWireguard = {
/**
* Delete a WireGuard client
*/
async deleteClient(knex, clientId) {
const client = await knex("wg_client").where("id", clientId).first();
async deleteClient(knex, clientId, access, accessData) {
const query = knex("wg_client").where("id", clientId);
if (access) {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
if (!client) {
throw new Error("Client not found");
}
await knex("wg_client").where("id", clientId).del();
// Hard-remove the encrypted partition safely mapped to the ipv4_address since it's deleted
await internalWireguardFs.deleteClientDir(client.ipv4_address);
await this.saveConfig(knex);
return { success: true };
@ -245,8 +364,12 @@ const internalWireguard = {
/**
* Toggle a WireGuard client enabled/disabled
*/
async toggleClient(knex, clientId, enabled) {
const client = await knex("wg_client").where("id", clientId).first();
async toggleClient(knex, clientId, enabled, access, accessData) {
const query = knex("wg_client").where("id", clientId);
if (access) {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
if (!client) {
throw new Error("Client not found");
}
@ -264,8 +387,12 @@ const internalWireguard = {
/**
* Update a WireGuard client
*/
async updateClient(knex, clientId, data) {
const client = await knex("wg_client").where("id", clientId).first();
async updateClient(knex, clientId, data, access, accessData) {
const query = knex("wg_client").where("id", clientId);
if (access) {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
if (!client) {
throw new Error("Client not found");
}
@ -287,21 +414,25 @@ const internalWireguard = {
* Get client configuration file content
*/
async getClientConfiguration(knex, clientId) {
const iface = await this.getOrCreateInterface(knex);
const client = await knex("wg_client").where("id", clientId).first();
if (!client) {
throw new Error("Client not found");
}
const iface = await knex("wg_interface").where("id", client.interface_id).first();
if (!iface) {
throw new Error("Interface not found for this client");
}
const endpoint = `${iface.host || "YOUR_SERVER_IP"}:${iface.listen_port}`;
return wgHelpers.generateClientConfig({
clientPrivateKey: client.private_key,
clientPrivateKey: decrypt(client.private_key),
clientAddress: `${client.ipv4_address}/32`,
dns: iface.dns,
mtu: iface.mtu,
serverPublicKey: iface.public_key,
preSharedKey: client.pre_shared_key,
preSharedKey: decrypt(client.pre_shared_key),
allowedIps: client.allowed_ips,
persistentKeepalive: client.persistent_keepalive,
endpoint: endpoint,
@ -317,20 +448,264 @@ const internalWireguard = {
},
/**
* Get the WireGuard interface info
* Create a new WireGuard Interface Endpoint
*/
async getInterfaceInfo(knex) {
const iface = await this.getOrCreateInterface(knex);
return {
id: iface.id,
name: iface.name,
public_key: iface.public_key,
ipv4_cidr: iface.ipv4_cidr,
listen_port: iface.listen_port,
mtu: iface.mtu,
dns: iface.dns,
host: iface.host,
async createInterface(knex, data, access, accessData) {
const existingIfaces = await knex("wg_interface").select("name", "listen_port");
if (existingIfaces.length >= 100) {
throw new Error("Maximum limit of 100 WireGuard servers reached.");
}
// Find the lowest available index between 0 and 99
const usedPorts = new Set(existingIfaces.map(i => i.listen_port));
let newIndex = 0;
while (usedPorts.has(51820 + newIndex)) {
newIndex++;
}
const name = `wg${newIndex}`;
const listen_port = 51820 + newIndex;
// Attempt to grab /24 subnets, ex 10.8.0.0/24 -> 10.8.1.0/24
const ipv4_cidr = `10.8.${newIndex}.1/24`;
// Generate keys
const privateKey = await wgHelpers.generatePrivateKey();
const publicKey = await wgHelpers.getPublicKey(privateKey);
const insertData = {
name,
private_key: encrypt(privateKey),
public_key: publicKey,
listen_port,
ipv4_cidr,
mtu: data.mtu || WG_DEFAULT_MTU,
dns: data.dns || WG_DEFAULT_DNS,
host: data.host || WG_HOST,
isolate_clients: data.isolate_clients || false,
owner_user_id: access ? access.token.getUserId(1) : 1,
post_up: "iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE",
post_down: "iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE",
created_on: knex.fn.now(),
modified_on: knex.fn.now(),
};
const [id] = await knex("wg_interface").insert(insertData);
const newIface = await knex("wg_interface").where("id", id).first();
// Regenerate config and restart the new interface seamlessly
const parsed = wgHelpers.parseCIDR(newIface.ipv4_cidr);
let configContent = wgHelpers.generateServerInterface({
privateKey: newIface.private_key,
address: `${parsed.firstHost}/${parsed.prefix}`,
listenPort: newIface.listen_port,
mtu: newIface.mtu,
dns: null,
postUp: newIface.post_up,
postDown: newIface.post_down,
});
fs.writeFileSync(`${WG_CONFIG_DIR}/${name}.conf`, configContent, { mode: 0o600 });
await wgHelpers.wgUp(name);
return newIface;
},
/**
* Update an existing Interface
*/
async updateInterface(knex, id, data, access, accessData) {
const query = knex("wg_interface").where("id", id);
if (access) {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const iface = await query.first();
if (!iface) throw new Error("Interface not found");
const updateData = { modified_on: knex.fn.now() };
if (data.host !== undefined) updateData.host = data.host;
if (data.dns !== undefined) updateData.dns = data.dns;
if (data.mtu !== undefined) updateData.mtu = data.mtu;
if (data.isolate_clients !== undefined) updateData.isolate_clients = data.isolate_clients;
await knex("wg_interface").where("id", id).update(updateData);
await this.saveConfig(knex); // This will re-render IPTables and sync
return knex("wg_interface").where("id", id).first();
},
/**
* Delete an interface
*/
async deleteInterface(knex, id, access, accessData) {
const query = knex("wg_interface").where("id", id);
if (access) {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const iface = await query.first();
if (!iface) throw new Error("Interface not found");
// Prevent deletion of the initial wg0 interface if it's the only one or a critical one
if (iface.name === "wg0") {
const otherIfaces = await knex("wg_interface").whereNot("id", id);
if (otherIfaces.length === 0) {
throw new Error("Cannot delete the initial wg0 interface. It is required.");
}
}
try {
await wgHelpers.wgDown(iface.name);
if (fs.existsSync(`${WG_CONFIG_DIR}/${iface.name}.conf`)) {
fs.unlinkSync(`${WG_CONFIG_DIR}/${iface.name}.conf`);
}
} catch (e) {
logger.warn(`Failed to teardown WG interface ${iface.name}: ${e.message}`);
}
// Pre-emptively Cascade delete all Clients & Partitions tied to this interface
const clients = await knex("wg_client").where("interface_id", iface.id);
for (const c of clients) {
await internalWireguardFs.deleteClientDir(c.ipv4_address);
}
await knex("wg_client").where("interface_id", iface.id).del();
// Cascading deletion handles links in DB schema
await knex("wg_interface").where("id", id).del();
return { success: true };
},
/**
* Update Peering Links between WireGuard Interfaces
*/
async updateInterfaceLinks(knex, id, linkedServers, access, accessData) {
// Verify ownership
const query = knex("wg_interface").where("id", id);
if (access) {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const iface = await query.first();
if (!iface) throw new Error("Interface not found");
// Clean up existing links where this interface is involved
await knex("wg_server_link").where("interface_id_1", id).orWhere("interface_id_2", id).del();
// Insert new ones
for (const peerId of linkedServers) {
if (peerId !== Number(id)) {
await knex("wg_server_link").insert({
interface_id_1: id,
interface_id_2: peerId
});
}
}
await this.saveConfig(knex);
return { success: true };
},
/**
* Get the WireGuard interfaces info
*/
async getInterfacesInfo(knex, access, accessData) {
const query = knex("wg_interface").select("*");
if (access) {
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
}
const ifaces = await query;
const allLinks = await knex("wg_server_link").select("*");
const allClients = await knex("wg_client").select("interface_id", "ipv4_address");
const result = [];
for (const i of ifaces) {
const links = allLinks.filter(l => l.interface_id_1 === i.id || l.interface_id_2 === i.id);
const client_count = allClients.filter(c => c.interface_id === i.id).length;
let storage_usage_bytes = 0;
for (const c of allClients.filter(c => c.interface_id === i.id)) {
storage_usage_bytes += await internalWireguardFs.getClientStorageUsage(c.ipv4_address);
}
result.push({
id: i.id,
name: i.name,
public_key: i.public_key,
ipv4_cidr: i.ipv4_cidr,
listen_port: i.listen_port,
mtu: i.mtu,
dns: i.dns,
host: i.host,
isolate_clients: i.isolate_clients,
linked_servers: links.map(l => l.interface_id_1 === i.id ? l.interface_id_2 : l.interface_id_1),
client_count,
storage_usage_bytes
});
}
return result;
},
/**
* Apply or remove the client isolation iptables rule for an interface.
* Called after every wg syncconf because PostUp/PostDown are not re-executed by syncconf.
*/
async syncIptablesRules(iface) {
const name = iface.name;
// Remove both possible same-interface rules first (idempotent)
await execAsync(`iptables -D FORWARD -i ${name} -o ${name} -j REJECT 2>/dev/null || true`);
await execAsync(`iptables -D FORWARD -i ${name} -o ${name} -j ACCEPT 2>/dev/null || true`);
// Re-insert at position 1 so it appears before the wg+ DROP rule in the chain
if (iface.isolate_clients) {
await execAsync(`iptables -I FORWARD -i ${name} -o ${name} -j REJECT`);
logger.info(`Client isolation enabled for ${name}`);
} else {
await execAsync(`iptables -I FORWARD -i ${name} -o ${name} -j ACCEPT`);
logger.info(`Client isolation disabled for ${name}`);
}
},
/**
* Run TC Traffic Control QoS limits on a WireGuard Interface (Bytes per sec)
*/
async applyBandwidthLimits(knex, iface) {
const clients = await knex("wg_client").where("interface_id", iface.id).where("enabled", true);
const cmds = [];
// Detach old qdiscs gracefully allowing error suppression
cmds.push(`tc qdisc del dev ${iface.name} root 2>/dev/null || true`);
cmds.push(`tc qdisc del dev ${iface.name} ingress 2>/dev/null || true`);
let hasLimits = false;
for (let i = 0; i < clients.length; i++) {
const client = clients[i];
if (client.tx_limit > 0 || client.rx_limit > 0) {
if (!hasLimits) {
cmds.push(`tc qdisc add dev ${iface.name} root handle 1: htb default 10`);
cmds.push(`tc class add dev ${iface.name} parent 1: classid 1:1 htb rate 10gbit`);
cmds.push(`tc qdisc add dev ${iface.name} handle ffff: ingress`);
hasLimits = true;
}
const mark = i + 10;
// client.rx_limit (Server -> Client = Download = root qdisc TX) - Rate is Bytes/sec so mult by 8 -> bits, /1000 -> Kbits
if (client.rx_limit > 0) {
const rateKbit = Math.floor((client.rx_limit * 8) / 1000);
cmds.push(`tc class add dev ${iface.name} parent 1:1 classid 1:${mark} htb rate ${rateKbit}kbit`);
cmds.push(`tc filter add dev ${iface.name} protocol ip parent 1:0 prio 1 u32 match ip dst ${client.ipv4_address}/32 flowid 1:${mark}`);
}
// client.tx_limit (Client -> Server = Upload = ingress qdisc RX)
if (client.tx_limit > 0) {
const rateKbit = Math.floor((client.tx_limit * 8) / 1000);
cmds.push(`tc filter add dev ${iface.name} parent ffff: protocol ip u32 match ip src ${client.ipv4_address}/32 police rate ${rateKbit}kbit burst 1m drop flowid :1`);
}
}
}
if (hasLimits) {
await execAsync(cmds.join(" && "));
}
},
/**
@ -356,6 +731,45 @@ const internalWireguard = {
if (needsSave) {
await this.saveConfig(knex);
}
// Audit Logging Polling
const ifaces = await knex("wg_interface").select("name");
const allClients = await knex("wg_client").select("id", "public_key", "name", "owner_user_id");
for (const iface of ifaces) {
try {
const dump = await wgHelpers.wgDump(iface.name);
for (const peer of dump) {
const client = allClients.find((c) => c.public_key === peer.publicKey);
if (client) {
const lastHandshakeTime = new Date(peer.latestHandshakeAt).getTime();
const wasConnected = connectionMemoryMap[client.id] || false;
const isConnected = lastHandshakeTime > 0 && (Date.now() - lastHandshakeTime < 3 * 60 * 1000);
if (isConnected && !wasConnected) {
connectionMemoryMap[client.id] = true;
// Log connection (dummy token signature for audit logic)
internalAuditLog.add({ token: { getUserId: () => client.owner_user_id } }, {
action: "connected",
meta: { message: `WireGuard client ${client.name} came online.` },
object_type: "wireguard-client",
object_id: client.id
}).catch(()=>{});
} else if (!isConnected && wasConnected) {
connectionMemoryMap[client.id] = false;
// Log disconnection
internalAuditLog.add({ token: { getUserId: () => client.owner_user_id } }, {
action: "disconnected",
meta: { message: `WireGuard client ${client.name} went offline or drifted past TTL.` },
object_type: "wireguard-client",
object_id: client.id
}).catch(()=>{});
}
}
}
} catch (_) {}
}
} catch (err) {
logger.error("WireGuard cron job error:", err.message);
}

96
backend/lib/crypto.js Normal file
View file

@ -0,0 +1,96 @@
/**
* Application-layer AES-256-GCM encryption for sensitive DB fields.
*
* Encrypted format stored in DB:
* enc:<base64(iv)>:<base64(authTag)>:<base64(ciphertext)>
*
* Set DB_ENCRYPTION_KEY to a 64-char hex string (32 bytes):
* export DB_ENCRYPTION_KEY=$(openssl rand -hex 32)
*
* If DB_ENCRYPTION_KEY is not set, values are stored as plaintext (with warning).
*/
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12; // 96-bit IV recommended for GCM
const TAG_LENGTH = 16; // 128-bit auth tag
const ENC_PREFIX = "enc:";
let _key = null;
let _warned = false;
function getKey() {
if (_key) return _key;
const hex = process.env.DB_ENCRYPTION_KEY;
if (!hex) {
if (!_warned) {
console.warn("[crypto] WARNING: DB_ENCRYPTION_KEY is not set. WireGuard private keys are stored as PLAINTEXT in the database.");
_warned = true;
}
return null;
}
if (hex.length !== 64) {
throw new Error("DB_ENCRYPTION_KEY must be a 64-character hex string (32 bytes). Generate with: openssl rand -hex 32");
}
_key = Buffer.from(hex, "hex");
return _key;
}
/**
* Encrypt a plaintext string.
* Returns the encrypted string with "enc:" prefix, or the original value if no key is set.
* @param {string} plaintext
* @returns {string}
*/
export function encrypt(plaintext) {
if (!plaintext) return plaintext;
// Already encrypted — idempotent
if (plaintext.startsWith(ENC_PREFIX)) return plaintext;
const key = getKey();
if (!key) return plaintext; // passthrough if no key configured
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return `${ENC_PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`;
}
/**
* Decrypt an encrypted string.
* Returns plaintext, or the original value if it is not encrypted.
* @param {string} value
* @returns {string}
*/
export function decrypt(value) {
if (!value) return value;
// Not encrypted — return as-is (backward compat with plaintext rows)
if (!value.startsWith(ENC_PREFIX)) return value;
const key = getKey();
if (!key) {
throw new Error("DB_ENCRYPTION_KEY is required to decrypt WireGuard keys but is not set.");
}
const parts = value.slice(ENC_PREFIX.length).split(":");
if (parts.length !== 3) {
throw new Error("Invalid encrypted value format in database.");
}
const [ivB64, tagB64, dataB64] = parts;
const iv = Buffer.from(ivB64, "base64");
const tag = Buffer.from(tagB64, "base64");
const data = Buffer.from(dataB64,"base64");
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8");
}

View file

@ -0,0 +1,66 @@
const migrate_name = "wireguard_multi_server";
/**
* Migration to add multi-server support to WireGuard tables
*/
export async function up(knex) {
// First, check if the tables exist
const hasInterfaceTable = await knex.schema.hasTable("wg_interface");
const hasClientTable = await knex.schema.hasTable("wg_client");
if (!hasInterfaceTable || !hasClientTable) {
throw new Error("Missing wg_interface or wg_client tables. Ensure previous migrations ran.");
}
// 1. Add isolate_clients to wg_interface
await knex.schema.alterTable("wg_interface", (table) => {
table.boolean("isolate_clients").notNullable().defaultTo(false);
});
// 2. Add interface_id to wg_client
await knex.schema.alterTable("wg_client", (table) => {
table.integer("interface_id").unsigned().nullable(); // Initially nullable to allow adding
});
// 3. Assign existing clients to the first interface (wg0)
const firstInterface = await knex("wg_interface").orderBy("id").first();
if (firstInterface) {
await knex("wg_client").whereNull("interface_id").update({
interface_id: firstInterface.id,
});
}
// 4. Make interface_id not nullable and add foreign key
await knex.schema.alterTable("wg_client", (table) => {
table.integer("interface_id")
.unsigned()
.notNullable()
.references("id")
.inTable("wg_interface")
.onDelete("CASCADE")
.alter();
});
// 5. Create wg_server_link for server peering
await knex.schema.createTable("wg_server_link", (table) => {
table.integer("interface_id_1").unsigned().notNullable()
.references("id").inTable("wg_interface").onDelete("CASCADE");
table.integer("interface_id_2").unsigned().notNullable()
.references("id").inTable("wg_interface").onDelete("CASCADE");
table.primary(["interface_id_1", "interface_id_2"]);
});
}
export async function down(knex) {
await knex.schema.dropTableIfExists("wg_server_link");
await knex.schema.alterTable("wg_client", (table) => {
table.dropForeign("interface_id");
table.dropColumn("interface_id");
});
await knex.schema.alterTable("wg_interface", (table) => {
table.dropColumn("isolate_clients");
});
}

View file

@ -0,0 +1,35 @@
/**
* Migration to add owner_user_id to WireGuard tables for user-based data isolation
*/
export async function up(knex) {
// 1. Add owner_user_id to wg_interface
await knex.schema.alterTable("wg_interface", (table) => {
table.integer("owner_user_id").unsigned().nullable();
});
// 2. Add owner_user_id to wg_client
await knex.schema.alterTable("wg_client", (table) => {
table.integer("owner_user_id").unsigned().nullable();
});
// 3. Backfill existing rows with admin user (id=1)
await knex("wg_interface").whereNull("owner_user_id").update({ owner_user_id: 1 });
await knex("wg_client").whereNull("owner_user_id").update({ owner_user_id: 1 });
// 4. Make columns not nullable
await knex.schema.alterTable("wg_interface", (table) => {
table.integer("owner_user_id").unsigned().notNullable().defaultTo(1).alter();
});
await knex.schema.alterTable("wg_client", (table) => {
table.integer("owner_user_id").unsigned().notNullable().defaultTo(1).alter();
});
}
export async function down(knex) {
await knex.schema.alterTable("wg_client", (table) => {
table.dropColumn("owner_user_id");
});
await knex.schema.alterTable("wg_interface", (table) => {
table.dropColumn("owner_user_id");
});
}

View file

@ -0,0 +1,18 @@
export const up = function (knex) {
return knex.schema.alterTable("wg_client", (table) => {
// Traffic Bandwidth Limits (0 = Unlimited)
table.bigInteger("tx_limit").notNull().defaultTo(0);
table.bigInteger("rx_limit").notNull().defaultTo(0);
// Disk Partition Ceiling Quota Configuration in Megabytes
table.integer("storage_limit_mb").notNull().defaultTo(500);
});
};
export const down = function (knex) {
return knex.schema.alterTable("wg_client", (table) => {
table.dropColumn("tx_limit");
table.dropColumn("rx_limit");
table.dropColumn("storage_limit_mb");
});
};

View file

@ -0,0 +1,61 @@
/**
* Data migration: encrypts existing plaintext WireGuard private keys
* and pre-shared keys in the database.
*
* This migration is safe to run multiple times (idempotent):
* already-encrypted values (prefix "enc:") are skipped.
*
* Requires DB_ENCRYPTION_KEY to be set in the environment.
* If not set, migration logs a warning and exits without modifying data.
*/
import { encrypt } from "../lib/crypto.js";
const migrate_name = "wireguard_encrypt_keys";
export async function up(knex) {
const key = process.env.DB_ENCRYPTION_KEY;
if (!key) {
console.warn(`[${migrate_name}] DB_ENCRYPTION_KEY not set — skipping encryption migration. Keys remain as plaintext.`);
return;
}
console.log(`[${migrate_name}] Encrypting existing WireGuard keys...`);
// --- wg_interface: encrypt private_key ---
const ifaces = await knex("wg_interface").select("id", "private_key");
let ifaceCount = 0;
for (const iface of ifaces) {
if (!iface.private_key || iface.private_key.startsWith("enc:")) continue;
await knex("wg_interface").where("id", iface.id).update({
private_key: encrypt(iface.private_key),
});
ifaceCount++;
}
console.log(`[${migrate_name}] wg_interface: ${ifaceCount} rows encrypted.`);
// --- wg_client: encrypt private_key + pre_shared_key ---
const clients = await knex("wg_client").select("id", "private_key", "pre_shared_key");
let clientCount = 0;
for (const client of clients) {
const updates = {};
if (client.private_key && !client.private_key.startsWith("enc:")) {
updates.private_key = encrypt(client.private_key);
}
if (client.pre_shared_key && !client.pre_shared_key.startsWith("enc:")) {
updates.pre_shared_key = encrypt(client.pre_shared_key);
}
if (Object.keys(updates).length > 0) {
await knex("wg_client").where("id", client.id).update(updates);
clientCount++;
}
}
console.log(`[${migrate_name}] wg_client: ${clientCount} rows encrypted.`);
console.log(`[${migrate_name}] Done.`);
}
export async function down(knex) {
// Intentionally a no-op: decrypting back to plaintext would require the key,
// and rolling back encryption is a security risk.
console.warn(`[${migrate_name}] down() is a no-op. Keys remain encrypted.`);
}

5810
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -23,7 +23,9 @@
"compression": "^1.8.1",
"express": "^5.2.1",
"express-fileupload": "^1.5.2",
"express-rate-limit": "^7.5.0",
"gravatar": "^1.8.2",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.3",
"knex": "3.1.0",
"liquidjs": "10.24.0",
@ -38,6 +40,7 @@
"proxy-agent": "^6.5.0",
"signale": "1.4.0",
"sqlite3": "^5.1.7",
"systeminformation": "^5.31.3",
"temp-write": "^6.0.1"
},
"devDependencies": {

View file

@ -0,0 +1,63 @@
import express from "express";
import internalDatabase from "../../internal/database.js";
import jwtdecode from "../../lib/express/jwt-decode.js";
import { debug, express as logger } from "../../logger.js";
const router = express.Router({
caseSensitive: true,
strict: true,
mergeParams: true,
});
router.use(jwtdecode());
/**
* Middleware to strictly ensure only Super Admins can access this route
*/
const requireSuperAdmin = async (req, res, next) => {
try {
const accessData = await res.locals.access.can("proxy_hosts:list");
if (!accessData || accessData.permission_visibility !== "all") {
return res.status(403).json({ error: { message: "Forbidden: Super Admin only" } });
}
next();
} catch (err) {
next(err);
}
};
router.use(requireSuperAdmin);
/**
* GET /api/database/tables
* List all tables in the database
*/
router.get("/tables", async (req, res, next) => {
try {
const tables = await internalDatabase.getTables();
res.status(200).json(tables);
} catch (err) {
debug(logger, `GET ${req.path} error: ${err.message}`);
next(err);
}
});
/**
* GET /api/database/tables/:name
* Get table schema and data rows
*/
router.get("/tables/:name", async (req, res, next) => {
try {
const limit = parseInt(req.query.limit, 10) || 50;
const offset = parseInt(req.query.offset, 10) || 0;
const name = req.params.name;
const data = await internalDatabase.getTableData(name, limit, offset);
res.status(200).json(data);
} catch (err) {
debug(logger, `GET ${req.path} error: ${err.message}`);
next(err);
}
});
export default router;

View file

@ -16,6 +16,7 @@ import tokensRoutes from "./tokens.js";
import usersRoutes from "./users.js";
import versionRoutes from "./version.js";
import wireguardRoutes from "./wireguard.js";
import databaseRoutes from "./api/database.js";
const router = express.Router({
caseSensitive: true,
@ -56,6 +57,7 @@ router.use("/nginx/streams", streamsRoutes);
router.use("/nginx/access-lists", accessListsRoutes);
router.use("/nginx/certificates", certificatesHostsRoutes);
router.use("/wireguard", wireguardRoutes);
router.use("/database", databaseRoutes);
/**
* API 404 for all other routes

View file

@ -1,3 +1,4 @@
import si from "systeminformation";
import express from "express";
import internalReport from "../internal/report.js";
import jwtdecode from "../lib/express/jwt-decode.js";
@ -29,4 +30,47 @@ router
}
});
/**
* GET /reports/system
*/
router
.route("/system")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
.get(async (req, res, next) => {
try {
const [cpuTotal, memData, networkStats, fsData] = await Promise.all([
si.currentLoad(),
si.mem(),
si.networkStats("*"),
si.fsSize()
]);
// Grab eth0 or the first active interface
const activeNet = networkStats.find(n => n.operstate === 'up' && n.iface !== 'lo') || networkStats[0] || {};
// Summarize all detected physical drives to find total storage and used storage
const totalStorage = fsData.reduce((acc, disk) => acc + (disk.size || 0), 0);
const usedStorage = fsData.reduce((acc, disk) => acc + (disk.used || 0), 0);
const storagePercent = totalStorage > 0 ? Math.round((usedStorage / totalStorage) * 100) : 0;
res.status(200).json({
cpu: Math.round(cpuTotal.currentLoad),
memory: Math.round((memData.active / memData.total) * 100),
memoryTotal: memData.total,
memoryActive: memData.active,
storage: storagePercent,
storageTotal: totalStorage,
storageUsed: usedStorage,
networkRx: (activeNet.rx_sec / 1024 / 1024 * 8).toFixed(2), // Mbps
networkTx: (activeNet.tx_sec / 1024 / 1024 * 8).toFixed(2), // Mbps
});
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
export default router;

150
backend/routes/wg_public.js Normal file
View file

@ -0,0 +1,150 @@
import express from "express";
import internalWireguardFs from "../internal/wireguard-fs.js";
import db from "../db.js";
const router = express.Router({
caseSensitive: true,
strict: true,
mergeParams: true,
});
/**
* Authenticate WireGuard client by tunnel remote socket IP
*/
const authenticateWgClientIp = async (req, res, next) => {
let clientIp = req.headers["x-forwarded-for"] || req.socket.remoteAddress || req.ip;
if (clientIp) {
if (clientIp.includes("::ffff:")) {
clientIp = clientIp.split("::ffff:")[1];
}
clientIp = clientIp.split(',')[0].trim();
}
if (!clientIp) {
return res.status(401).json({ error: { message: "Unknown remote IP address" } });
}
try {
const knex = db();
const client = await knex("wg_client").where("ipv4_address", clientIp).first();
if (!client) {
return res.status(401).json({ error: { message: `Unauthorized: IP ${clientIp} does not match any registered WireGuard Client in the Hub Database` } });
}
req.wgClient = client;
next();
} catch (err) {
next(err);
}
};
router.use(authenticateWgClientIp);
/**
* GET /api/wg-public/me
* Returns connection metrics and identity details dynamically mapped to this IP
*/
router.get("/me", async (req, res, next) => {
try {
const totalStorageBytes = await internalWireguardFs.getClientStorageUsage(req.wgClient.ipv4_address);
res.status(200).json({
id: req.wgClient.id,
name: req.wgClient.name,
ipv4_address: req.wgClient.ipv4_address,
enabled: !!req.wgClient.enabled,
rx_limit: req.wgClient.rx_limit,
tx_limit: req.wgClient.tx_limit,
storage_limit_mb: req.wgClient.storage_limit_mb,
transfer_rx: req.wgClient.transfer_rx,
transfer_tx: req.wgClient.transfer_tx,
storage_usage_bytes: totalStorageBytes
});
} catch (err) {
next(err);
}
});
/**
* GET /api/wg-public/files
* Fetch encrypted files directory securely
*/
router.get("/files", async (req, res, next) => {
try {
const files = await internalWireguardFs.listFiles(req.wgClient.ipv4_address);
res.status(200).json(files);
} catch (err) {
if (err.code === "ENOENT") return res.status(200).json([]);
next(err);
}
});
/**
* POST /api/wg-public/files
* Upload directly into backend AES storage limits
*/
router.post("/files", async (req, res, next) => {
try {
if (!req.files || !req.files.file) {
return res.status(400).json({ error: { message: "No file provided" } });
}
const file = req.files.file;
if (req.wgClient.storage_limit_mb > 0) {
const existingStorage = await internalWireguardFs.getClientStorageUsage(req.wgClient.ipv4_address);
if (existingStorage + file.size > req.wgClient.storage_limit_mb * 1024 * 1024) {
return res.status(413).json({ error: { message: "Storage Quota Exceeded limits assigned by Administrator" } });
}
}
await internalWireguardFs.uploadFile(req.wgClient.ipv4_address, req.wgClient.pre_shared_key, file.name, file.data);
res.status(200).json({ success: true, message: "File encrypted and saved safely via your Wireguard IP Auth!" });
} catch (err) {
next(err);
}
});
/**
* GET /api/wg-public/files/:filename
* Decrypt stream
*/
router.get("/files/:filename", async (req, res, next) => {
try {
const filename = req.params.filename;
await internalWireguardFs.downloadFile(req.wgClient.ipv4_address, req.wgClient.pre_shared_key, filename, res);
} catch (err) {
next(err);
}
});
/**
* PATCH /api/wg-public/files/:filename
* Rename an encrypted file
*/
router.patch("/files/:filename", async (req, res, next) => {
try {
const oldName = req.params.filename;
const newName = req.body?.name?.trim();
if (!newName) {
return res.status(400).json({ error: { message: "New file name is required" } });
}
await internalWireguardFs.renameFile(req.wgClient.ipv4_address, oldName, newName);
res.status(200).json({ success: true, message: "File renamed successfully" });
} catch (err) {
next(err);
}
});
/**
* DELETE /api/wg-public/files/:filename
*/
router.delete("/files/:filename", async (req, res, next) => {
try {
const filename = req.params.filename;
await internalWireguardFs.deleteFile(req.wgClient.ipv4_address, filename);
res.status(200).json({ success: true, message: "Destroyed safely" });
} catch (err) {
next(err);
}
});
export default router;

View file

@ -1,5 +1,9 @@
import express from "express";
import archiver from "archiver";
import internalWireguard from "../internal/wireguard.js";
import internalWireguardFs from "../internal/wireguard-fs.js";
import internalAuditLog from "../internal/audit-log.js";
import jwtdecode from "../lib/express/jwt-decode.js";
import db from "../db.js";
const router = express.Router({
@ -8,28 +12,180 @@ const router = express.Router({
mergeParams: true,
});
// Protect all WireGuard routes
router.use(jwtdecode());
/**
* GET /api/wireguard
* Get WireGuard interface info
* Get WireGuard interfaces info
*/
router.get("/", async (req, res, next) => {
router.get("/", async (_req, res, next) => {
try {
const knex = db();
const iface = await internalWireguard.getInterfaceInfo(knex);
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:list");
const ifaces = await internalWireguard.getInterfacesInfo(knex, access, accessData);
res.status(200).json(ifaces);
} catch (err) {
next(err);
}
});
/**
* GET /api/wireguard/dashboard
* Aggregated analytics for the main dashboard
*/
router.get("/dashboard", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:list");
const clients = await internalWireguard.getClients(knex, access, accessData);
let totalStorageBytes = 0;
let totalTransferRx = 0;
let totalTransferTx = 0;
let online24h = 0;
let online7d = 0;
let online30d = 0;
const now = Date.now();
const DAY = 24 * 60 * 60 * 1000;
for (const client of clients) {
try {
totalStorageBytes += await internalWireguardFs.getClientStorageUsage(client.ipv4_address);
} catch (_) {}
totalTransferRx += parseInt(client.transfer_rx || 0, 10);
totalTransferTx += parseInt(client.transfer_tx || 0, 10);
if (client.latest_handshake_at) {
const handshakeStr = String(client.latest_handshake_at);
let handshakeTime = Date.parse(handshakeStr);
// Handle 0 or invalid epoch
if (handshakeTime > 0) {
if (now - handshakeTime <= DAY) online24h++;
if (now - handshakeTime <= 7 * DAY) online7d++;
if (now - handshakeTime <= 30 * DAY) online30d++;
}
}
}
res.status(200).json({
totalStorageBytes,
totalTransferRx,
totalTransferTx,
online24h,
online7d,
online30d,
totalClients: clients.length
});
} catch (err) {
next(err);
}
});
/**
* POST /api/wireguard
* Create a new WireGuard interface
*/
router.post("/", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:create");
const iface = await internalWireguard.createInterface(knex, req.body, access, accessData);
await internalAuditLog.add(access, {
action: "created",
object_type: "wireguard-server",
object_id: iface.id,
meta: req.body,
});
res.status(201).json(iface);
} catch (err) {
next(err);
}
});
/**
* PUT /api/wireguard/:id
* Update a WireGuard interface
*/
router.put("/:id", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:update");
const iface = await internalWireguard.updateInterface(knex, req.params.id, req.body, access, accessData);
await internalAuditLog.add(access, {
action: "updated",
object_type: "wireguard-server",
object_id: iface.id,
meta: req.body,
});
res.status(200).json(iface);
} catch (err) {
next(err);
}
});
/**
* DELETE /api/wireguard/:id
* Delete a WireGuard interface
*/
router.delete("/:id", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:delete");
const result = await internalWireguard.deleteInterface(knex, req.params.id, access, accessData);
await internalAuditLog.add(access, {
action: "deleted",
object_type: "wireguard-server",
object_id: req.params.id,
meta: {},
});
res.status(200).json(result);
} catch (err) {
next(err);
}
});
/**
* POST /api/wireguard/:id/links
* Update peering links for a WireGuard interface
*/
router.post("/:id/links", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:update");
const result = await internalWireguard.updateInterfaceLinks(knex, req.params.id, req.body.linked_servers || [], access, accessData);
await internalAuditLog.add(access, {
action: "updated",
object_type: "wireguard-server-links",
object_id: req.params.id,
meta: req.body,
});
res.status(200).json(result);
} catch (err) {
next(err);
}
});
/**
* GET /api/wireguard/client
* List all WireGuard clients with live status
*/
router.get("/client", async (req, res, next) => {
router.get("/client", async (_req, res, next) => {
try {
const knex = db();
const clients = await internalWireguard.getClients(knex);
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:list");
const clients = await internalWireguard.getClients(knex, access, accessData);
res.status(200).json(clients);
} catch (err) {
next(err);
@ -43,7 +199,15 @@ router.get("/client", async (req, res, next) => {
router.post("/client", async (req, res, next) => {
try {
const knex = db();
const client = await internalWireguard.createClient(knex, req.body);
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:create");
const client = await internalWireguard.createClient(knex, req.body, access, accessData);
await internalAuditLog.add(access, {
action: "created",
object_type: "wireguard-client",
object_id: client.id,
meta: req.body,
});
res.status(201).json(client);
} catch (err) {
next(err);
@ -57,7 +221,13 @@ router.post("/client", async (req, res, next) => {
router.get("/client/:id", async (req, res, next) => {
try {
const knex = db();
const client = await knex("wg_client").where("id", req.params.id).first();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:get");
const query = knex("wg_client").where("id", req.params.id);
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
if (!client) {
return res.status(404).json({ error: { message: "Client not found" } });
}
@ -74,7 +244,15 @@ router.get("/client/:id", async (req, res, next) => {
router.put("/client/:id", async (req, res, next) => {
try {
const knex = db();
const client = await internalWireguard.updateClient(knex, req.params.id, req.body);
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:update");
const client = await internalWireguard.updateClient(knex, req.params.id, req.body, access, accessData);
await internalAuditLog.add(access, {
action: "updated",
object_type: "wireguard-client",
object_id: client.id,
meta: req.body,
});
res.status(200).json(client);
} catch (err) {
next(err);
@ -88,7 +266,15 @@ router.put("/client/:id", async (req, res, next) => {
router.delete("/client/:id", async (req, res, next) => {
try {
const knex = db();
const result = await internalWireguard.deleteClient(knex, req.params.id);
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:delete");
const result = await internalWireguard.deleteClient(knex, req.params.id, access, accessData);
await internalAuditLog.add(access, {
action: "deleted",
object_type: "wireguard-client",
object_id: req.params.id,
meta: {},
});
res.status(200).json(result);
} catch (err) {
next(err);
@ -102,7 +288,15 @@ router.delete("/client/:id", async (req, res, next) => {
router.post("/client/:id/enable", async (req, res, next) => {
try {
const knex = db();
const client = await internalWireguard.toggleClient(knex, req.params.id, true);
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:update");
const client = await internalWireguard.toggleClient(knex, req.params.id, true, access, accessData);
await internalAuditLog.add(access, {
action: "enabled",
object_type: "wireguard-client",
object_id: client.id,
meta: {},
});
res.status(200).json(client);
} catch (err) {
next(err);
@ -116,7 +310,15 @@ router.post("/client/:id/enable", async (req, res, next) => {
router.post("/client/:id/disable", async (req, res, next) => {
try {
const knex = db();
const client = await internalWireguard.toggleClient(knex, req.params.id, false);
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:update");
const client = await internalWireguard.toggleClient(knex, req.params.id, false, access, accessData);
await internalAuditLog.add(access, {
action: "disabled",
object_type: "wireguard-client",
object_id: client.id,
meta: {},
});
res.status(200).json(client);
} catch (err) {
next(err);
@ -130,7 +332,13 @@ router.post("/client/:id/disable", async (req, res, next) => {
router.get("/client/:id/configuration", async (req, res, next) => {
try {
const knex = db();
const client = await knex("wg_client").where("id", req.params.id).first();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:get");
const query = knex("wg_client").where("id", req.params.id);
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
if (!client) {
return res.status(404).json({ error: { message: "Client not found" } });
}
@ -151,6 +359,16 @@ router.get("/client/:id/configuration", async (req, res, next) => {
router.get("/client/:id/qrcode.svg", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:get");
const query = knex("wg_client").where("id", req.params.id);
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
if (!client) {
return res.status(404).json({ error: { message: "Client not found" } });
}
const svg = await internalWireguard.getClientQRCode(knex, req.params.id);
res.set("Content-Type", "image/svg+xml");
res.status(200).send(svg);
@ -159,4 +377,233 @@ router.get("/client/:id/qrcode.svg", async (req, res, next) => {
}
});
/**
* GET /api/wireguard/client/:id/configuration.zip
* Download WireGuard client configuration as a ZIP archive
*/
router.get("/client/:id/configuration.zip", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:get");
const query = knex("wg_client").where("id", req.params.id);
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
if (!client) {
return res.status(404).json({ error: { message: "Client not found" } });
}
const configStr = await internalWireguard.getClientConfiguration(knex, req.params.id);
const svgStr = await internalWireguard.getClientQRCode(knex, req.params.id);
const safeName = client.name.replace(/[^a-zA-Z0-9_.-]/g, "-").substring(0, 32);
res.set("Content-Disposition", `attachment; filename="${safeName}.zip"`);
res.set("Content-Type", "application/zip");
const archive = archiver("zip", { zlib: { level: 9 } });
archive.on("error", (err) => next(err));
archive.pipe(res);
archive.append(configStr, { name: `${safeName}.conf` });
archive.append(svgStr, { name: `${safeName}-qrcode.svg` });
await archive.finalize();
} catch (err) {
next(err);
}
});
/**
* GET /api/wireguard/client/:id/storage
* Get storage usage for a client
*/
router.get("/client/:id/storage", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:get");
const query = knex("wg_client").where("id", req.params.id);
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
if (!client) {
return res.status(404).json({ error: { message: "Client not found" } });
}
const totalBytes = await internalWireguardFs.getClientStorageUsage(client.ipv4_address);
res.status(200).json({ totalBytes, limitMb: client.storage_limit_mb });
} catch (err) {
next(err);
}
});
/**
* GET /api/wireguard/client/:id/logs
* Get connection history logs for a client
*/
router.get("/client/:id/logs", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:get");
const query = knex("wg_client").where("id", req.params.id);
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
if (!client) {
return res.status(404).json({ error: { message: "Client not found" } });
}
const logs = await knex("audit_log")
.where("object_type", "wireguard-client")
.andWhere("object_id", req.params.id)
.orderBy("created_on", "desc")
.limit(100);
res.status(200).json(logs);
} catch (err) {
next(err);
}
});
/**
* GET /api/wireguard/client/:id/files
* List files for a client
*/
router.get("/client/:id/files", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:get");
const query = knex("wg_client").where("id", req.params.id);
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
if (!client) {
return res.status(404).json({ error: { message: "Client not found" } });
}
const files = await internalWireguardFs.listFiles(client.ipv4_address);
res.status(200).json(files);
} catch (err) {
next(err);
}
});
/**
* POST /api/wireguard/client/:id/files
* Upload an encrypted file for a client
*/
router.post("/client/:id/files", async (req, res, next) => {
try {
if (!req.files || !req.files.file) {
return res.status(400).json({ error: { message: "No file uploaded" } });
}
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:update");
const query = knex("wg_client").where("id", req.params.id);
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
if (!client) {
return res.status(404).json({ error: { message: "Client not found" } });
}
const uploadedFile = req.files.file;
// Enforce Storage Quota if not unlimited (0)
if (client.storage_limit_mb > 0) {
const currentUsageBytes = await internalWireguardFs.getClientStorageUsage(client.ipv4_address);
const requestedSize = uploadedFile.size;
const maxBytes = client.storage_limit_mb * 1024 * 1024;
if (currentUsageBytes + requestedSize > maxBytes) {
return res.status(413).json({
error: {
message: `Storage Quota Exceeded. Maximum allowed: ${client.storage_limit_mb} MB.`
}
});
}
}
const result = await internalWireguardFs.uploadFile(client.ipv4_address, client.private_key, uploadedFile.name, uploadedFile.data);
await internalAuditLog.add(access, {
action: "uploaded-file",
object_type: "wireguard-client",
object_id: client.id,
meta: { filename: uploadedFile.name }
});
res.status(200).json(result);
} catch (err) {
next(err);
}
});
/**
* GET /api/wireguard/client/:id/files/:filename
* Download a decrypted file for a client
*/
router.get("/client/:id/files/:filename", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:get");
const query = knex("wg_client").where("id", req.params.id);
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
if (!client) {
return res.status(404).json({ error: { message: "Client not found" } });
}
await internalWireguardFs.downloadFile(client.ipv4_address, client.private_key, req.params.filename, res);
} catch (err) {
next(err);
}
});
/**
* DELETE /api/wireguard/client/:id/files/:filename
* Delete a file for a client
*/
router.delete("/client/:id/files/:filename", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:update");
const query = knex("wg_client").where("id", req.params.id);
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
if (!client) {
return res.status(404).json({ error: { message: "Client not found" } });
}
const result = await internalWireguardFs.deleteFile(client.ipv4_address, req.params.filename);
await internalAuditLog.add(access, {
action: "deleted-file",
object_type: "wireguard-client",
object_id: client.id,
meta: { filename: req.params.filename }
});
res.status(200).json(result);
} catch (err) {
next(err);
}
});
export default router;

View file

@ -1,9 +1,9 @@
{
"openapi": "3.1.0",
"info": {
"title": "Nginx Proxy Manager API",
"title": "xGat3 API",
"version": "2.x.x",
"description": "This is the official API documentation for Nginx Proxy Manager.\n\nMost endpoints require authentication via Bearer Token (JWT). You can generate a token by logging in via the `POST /tokens` endpoint.\n\nFor more information, visit the [Nginx Proxy Manager Documentation](https://nginxproxymanager.com)."
"description": "This is the official API documentation for xGat3.\n\nMost endpoints require authentication via Bearer Token (JWT). You can generate a token by logging in via the `POST /tokens` endpoint."
},
"servers": [
{

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,116 @@
name: Create Blog Post
on:
workflow_dispatch:
inputs:
title:
description: "Post title"
required: true
type: string
slug:
description: "URL slug, for example my-new-post"
required: true
type: string
summary:
description: "Short summary"
required: false
type: string
tags:
description: "Comma-separated tags"
required: false
type: string
categories:
description: "Comma-separated categories"
required: false
type: string
draft:
description: "Create as draft"
required: true
type: boolean
default: true
jobs:
create-post:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate post file
env:
INPUT_TITLE: ${{ inputs.title }}
INPUT_SLUG: ${{ inputs.slug }}
INPUT_SUMMARY: ${{ inputs.summary }}
INPUT_TAGS: ${{ inputs.tags }}
INPUT_CATEGORIES: ${{ inputs.categories }}
INPUT_DRAFT: ${{ inputs.draft }}
run: |
python3 - <<'PY'
import os
import re
from datetime import datetime, timezone
from pathlib import Path
def split_csv(value: str):
if not value:
return []
return [item.strip() for item in value.split(",") if item.strip()]
title = os.environ["INPUT_TITLE"].strip()
slug = os.environ["INPUT_SLUG"].strip().lower()
summary = os.environ.get("INPUT_SUMMARY", "").strip()
draft = os.environ.get("INPUT_DRAFT", "true").strip().lower() == "true"
tags = split_csv(os.environ.get("INPUT_TAGS", ""))
categories = split_csv(os.environ.get("INPUT_CATEGORIES", ""))
if not title:
raise SystemExit("Title is required")
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", slug):
raise SystemExit("Slug must use lowercase letters, numbers, and hyphens only")
content_dir = Path("content/posts")
content_dir.mkdir(parents=True, exist_ok=True)
target = content_dir / f"{slug}.md"
if target.exists():
raise SystemExit(f"Post already exists: {target}")
now = datetime.now(timezone.utc).astimezone().replace(microsecond=0).isoformat()
def format_array(values):
if not values:
return "[]"
return "[{}]".format(", ".join(f'\"{value}\"' for value in values))
escaped_title = title.replace('"', '\\"')
escaped_summary = summary.replace('"', '\\"')
body = f"""++++
title = \"{escaped_title}\"
date = {now}
draft = {\"true\" if draft else \"false\"}
slug = \"{slug}\"
summary = \"{escaped_summary}\"
tags = {format_array(tags)}
categories = {format_array(categories)}
++++
Write your post here.
"""
target.write_text(body, encoding="utf-8")
print(target)
PY
- name: Commit new post
env:
POST_SLUG: ${{ inputs.slug }}
run: |
git config user.name "forgejo-actions"
git config user.email "forgejo-actions@localhost"
git add "content/posts/${POST_SLUG}.md"
git commit -m "Create post: ${POST_SLUG}"
git push

View file

@ -0,0 +1,72 @@
name: Deploy Blog
on:
push:
branches:
- master
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y curl git rsync openssh-client tar
- name: Install Hugo Extended
env:
HUGO_VERSION: "0.145.0"
run: |
curl -fsSL -o hugo.deb \
"https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb"
sudo dpkg -i hugo.deb
hugo version
- name: Bootstrap LoveIt theme
run: |
chmod +x scripts/bootstrap-theme.sh
./scripts/bootstrap-theme.sh
- name: Build site
run: |
hugo --gc --minify
test -f public/index.html
- name: Configure SSH
env:
BLOG_DEPLOY_KEY: ${{ secrets.BLOG_DEPLOY_KEY }}
BLOG_DEPLOY_KNOWN_HOSTS: ${{ secrets.BLOG_DEPLOY_KNOWN_HOSTS }}
run: |
install -d -m 700 ~/.ssh
printf '%s\n' "$BLOG_DEPLOY_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
if [ -n "${BLOG_DEPLOY_KNOWN_HOSTS}" ]; then
printf '%s\n' "$BLOG_DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
fi
- name: Deploy to server
env:
BLOG_DEPLOY_HOST: ${{ secrets.BLOG_DEPLOY_HOST }}
BLOG_DEPLOY_PORT: ${{ secrets.BLOG_DEPLOY_PORT }}
BLOG_DEPLOY_USER: ${{ secrets.BLOG_DEPLOY_USER }}
BLOG_DEPLOY_PATH: ${{ secrets.BLOG_DEPLOY_PATH }}
run: |
test -n "$BLOG_DEPLOY_HOST"
test -n "$BLOG_DEPLOY_PORT"
test -n "$BLOG_DEPLOY_USER"
test -n "$BLOG_DEPLOY_PATH"
SSH_OPTS="-p $BLOG_DEPLOY_PORT"
if [ ! -f ~/.ssh/known_hosts ]; then
SSH_OPTS="$SSH_OPTS -o StrictHostKeyChecking=accept-new"
fi
ssh $SSH_OPTS "$BLOG_DEPLOY_USER@$BLOG_DEPLOY_HOST" "mkdir -p '$BLOG_DEPLOY_PATH/public'"
rsync -az --delete \
-e "ssh $SSH_OPTS" \
public/ "$BLOG_DEPLOY_USER@$BLOG_DEPLOY_HOST:$BLOG_DEPLOY_PATH/public/"

4
blog-starter/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/public/
/resources/
/themes/LoveIt/
.hugo_build.lock

86
blog-starter/README.md Normal file
View file

@ -0,0 +1,86 @@
# Blog Starter
This folder is a standalone starter for a Hugo blog that uses the LoveIt theme
and deploys to the static blog host installed by `install.sh`.
## What This Starter Includes
- Hugo site structure
- LoveIt bootstrap script
- sample content
- a helper script to create new posts
- a Forgejo Actions workflow that builds and deploys to `/opt/blog/public`
## Recommended Setup
Create a separate Git repository for your blog, then copy this starter into that
repository root.
## Local Writing Workflow
1. Bootstrap the theme:
```bash
./scripts/bootstrap-theme.sh
```
2. Create a new post:
```bash
./scripts/new-post.sh my-first-post
```
3. Start the local preview server:
```bash
hugo server
```
4. Edit the generated file under `content/posts/`.
5. When ready to publish, set `draft = false`, commit, and push.
## Forgejo Web Workflow
If you want to create posts directly from the Forgejo web UI, use the manual
workflow in `.forgejo/workflows/create-post.yml`.
From the Actions page:
1. Run `Create Blog Post`
2. Fill in:
- `title`
- `slug`
- optional `summary`
- optional comma-separated `tags`
- optional comma-separated `categories`
- `draft`
3. The workflow creates `content/posts/<slug>.md`
4. The normal deploy workflow publishes the post on the next push
This keeps you out of front matter for most day-to-day writing.
## Forgejo Secrets
The workflow expects these repository secrets:
- `BLOG_DEPLOY_HOST`: server hostname or IP
- `BLOG_DEPLOY_PORT`: SSH port, usually `22`
- `BLOG_DEPLOY_USER`: deploy user on the server
- `BLOG_DEPLOY_KEY`: private SSH key for the deploy user
- `BLOG_DEPLOY_PATH`: target directory, usually `/opt/blog`
- `BLOG_DEPLOY_KNOWN_HOSTS`: optional `known_hosts` entry for stricter SSH
## Expected Server State
- Blog host installed from this repo's `install.sh`
- `d3v-blog` container running
- a deploy user created by `install.sh blog-install` or `install.sh blog-update`
- `install.sh blog-deploy-info` prints the exact secret values to paste into Forgejo
- Nginx Proxy Manager forwards `blog.yourdomain.com` to `d3v-blog:80`
## Notes
- This starter fetches LoveIt into `themes/LoveIt` using Git.
- The workflow installs Hugo Extended before building.
- Deployment uses `rsync --delete` to keep `/opt/blog/public` in sync with the
latest generated `public/` output.

View file

@ -0,0 +1,10 @@
+++
title = "{{ replace .Name "-" " " | title }}"
date = {{ .Date }}
draft = true
slug = "{{ .Name }}"
tags = []
categories = []
+++
Write your post here.

View file

@ -0,0 +1,9 @@
+++
title = "About"
date = 2026-03-19T12:00:00+07:00
draft = false
+++
This is the About page for your Hugo blog.
Update this page with your own bio, project information, or contact details.

View file

@ -0,0 +1,7 @@
+++
title = "Posts"
date = 2026-03-19T12:00:00+07:00
draft = false
+++
Latest posts from the blog.

View file

@ -0,0 +1,17 @@
+++
title = "Hello World"
date = 2026-03-19T12:00:00+07:00
draft = false
slug = "hello-world"
tags = ["welcome", "hugo"]
categories = ["general"]
+++
This is the first post published from the Hugo + LoveIt starter.
When you are ready to write your own content:
1. Create a new post with `./scripts/new-post.sh your-post-slug`
2. Edit the generated Markdown file
3. Preview with `hugo server`
4. Commit and push to trigger deployment

35
blog-starter/hugo.toml Normal file
View file

@ -0,0 +1,35 @@
baseURL = "https://blog.example.com/"
languageCode = "en-us"
title = "D3V Blog"
theme = "LoveIt"
defaultContentLanguage = "en"
enableRobotsTXT = true
[permalinks]
posts = "/posts/:slug/"
[params]
defaultTheme = "auto"
dateFormat = "2006-01-02"
description = "Notes, updates, and guides."
[params.author]
name = "D3V Team"
[markup]
[markup.goldmark]
[markup.goldmark.renderer]
unsafe = true
[menu]
[[menu.main]]
identifier = "posts"
name = "Posts"
url = "/posts/"
weight = 1
[[menu.main]]
identifier = "about"
name = "About"
url = "/about/"
weight = 2

View file

@ -0,0 +1,25 @@
#!/bin/sh
set -eu
THEME_DIR="themes/LoveIt"
THEME_REPO="https://github.com/dillonzq/LoveIt.git"
THEME_REF="${LOVEIT_REF:-v0.3.1}"
mkdir -p themes
if [ -d "${THEME_DIR}/.git" ]; then
echo "Updating LoveIt theme..."
git -C "${THEME_DIR}" fetch --depth 1 origin "${THEME_REF}"
git -C "${THEME_DIR}" checkout -f FETCH_HEAD
else
echo "Cloning LoveIt theme..."
git clone --depth 1 --branch "${THEME_REF}" "${THEME_REPO}" "${THEME_DIR}"
fi
if [ -f "${THEME_DIR}/.gitmodules" ]; then
echo "Initializing LoveIt submodules..."
git -C "${THEME_DIR}" submodule sync --recursive
git -C "${THEME_DIR}" submodule update --init --recursive
fi
echo "LoveIt theme is ready in ${THEME_DIR}"

View file

@ -0,0 +1,11 @@
#!/bin/sh
set -eu
if [ "${1:-}" = "" ]; then
echo "Usage: $0 my-post-slug" >&2
exit 1
fi
slug="$1"
hugo new "posts/${slug}.md"
echo "Created content/posts/${slug}.md"

View file

@ -0,0 +1 @@

View file

@ -76,7 +76,7 @@ ENTRYPOINT [ "/init" ]
LABEL org.label-schema.schema-version="1.0" \
org.label-schema.license="MIT" \
org.label-schema.name="d3v-npmwg" \
org.label-schema.description="D3V-NPMWG: Nginx Proxy Manager + WireGuard VPN Manager" \
org.label-schema.url="https://github.com/xtcnet/D3V-NPMWG" \
org.label-schema.cmd="docker run --rm -ti --cap-add=NET_ADMIN --cap-add=SYS_MODULE d3v-npmwg:latest"
org.label-schema.name="d3v-gateway" \
org.label-schema.description="D3V Gateway: Reverse Proxy + WireGuard VPN Manager" \
org.label-schema.url="https://src.d3v.ac/d3v/D3V-Server" \
org.label-schema.cmd="docker run --rm -ti --cap-add=NET_ADMIN --cap-add=SYS_MODULE src.d3v.ac/d3v/d3v-server:latest"

View file

@ -1,255 +1 @@
/*
How this was generated:
1. bring up an empty pdns stack
2. use api to create a zone ...
curl -X POST \
'http://npm.dev:8081/api/v1/servers/localhost/zones' \
--header 'X-API-Key: npm' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "example.com.",
"kind": "Native",
"masters": [],
"nameservers": [
"ns1.pdns.",
"ns2.pdns."
]
}'
3. Dump sql:
docker exec -ti npm.pdns.db mysqldump -u pdns -p pdns
*/
----------------------------------------------------------------------
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `comments`
--
DROP TABLE IF EXISTS `comments`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `comments` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`domain_id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
`type` varchar(10) NOT NULL,
`modified_at` int(11) NOT NULL,
`account` varchar(40) CHARACTER SET utf8mb3 DEFAULT NULL,
`comment` text CHARACTER SET utf8mb3 NOT NULL,
PRIMARY KEY (`id`),
KEY `comments_name_type_idx` (`name`,`type`),
KEY `comments_order_idx` (`domain_id`,`modified_at`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `comments`
--
LOCK TABLES `comments` WRITE;
/*!40000 ALTER TABLE `comments` DISABLE KEYS */;
/*!40000 ALTER TABLE `comments` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `cryptokeys`
--
DROP TABLE IF EXISTS `cryptokeys`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `cryptokeys` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`domain_id` int(11) NOT NULL,
`flags` int(11) NOT NULL,
`active` tinyint(1) DEFAULT NULL,
`published` tinyint(1) DEFAULT 1,
`content` text DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `domainidindex` (`domain_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `cryptokeys`
--
LOCK TABLES `cryptokeys` WRITE;
/*!40000 ALTER TABLE `cryptokeys` DISABLE KEYS */;
/*!40000 ALTER TABLE `cryptokeys` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `domainmetadata`
--
DROP TABLE IF EXISTS `domainmetadata`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `domainmetadata` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`domain_id` int(11) NOT NULL,
`kind` varchar(32) DEFAULT NULL,
`content` text DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `domainmetadata_idx` (`domain_id`,`kind`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=latin1;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `domainmetadata`
--
LOCK TABLES `domainmetadata` WRITE;
/*!40000 ALTER TABLE `domainmetadata` DISABLE KEYS */;
INSERT INTO `domainmetadata` VALUES
(1,1,'SOA-EDIT-API','DEFAULT');
/*!40000 ALTER TABLE `domainmetadata` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `domains`
--
DROP TABLE IF EXISTS `domains`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `domains` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`master` varchar(128) DEFAULT NULL,
`last_check` int(11) DEFAULT NULL,
`type` varchar(8) NOT NULL,
`notified_serial` int(10) unsigned DEFAULT NULL,
`account` varchar(40) CHARACTER SET utf8mb3 DEFAULT NULL,
`options` varchar(64000) DEFAULT NULL,
`catalog` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name_index` (`name`),
KEY `catalog_idx` (`catalog`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=latin1;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `domains`
--
LOCK TABLES `domains` WRITE;
/*!40000 ALTER TABLE `domains` DISABLE KEYS */;
INSERT INTO `domains` VALUES
(1,'example.com','',NULL,'NATIVE',NULL,'',NULL,NULL);
/*!40000 ALTER TABLE `domains` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `records`
--
DROP TABLE IF EXISTS `records`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `records` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`domain_id` int(11) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
`type` varchar(10) DEFAULT NULL,
`content` varchar(64000) DEFAULT NULL,
`ttl` int(11) DEFAULT NULL,
`prio` int(11) DEFAULT NULL,
`disabled` tinyint(1) DEFAULT 0,
`ordername` varchar(255) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
`auth` tinyint(1) DEFAULT 1,
PRIMARY KEY (`id`),
KEY `nametype_index` (`name`,`type`),
KEY `domain_id` (`domain_id`),
KEY `ordername` (`ordername`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=latin1;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `records`
--
LOCK TABLES `records` WRITE;
/*!40000 ALTER TABLE `records` DISABLE KEYS */;
INSERT INTO `records` VALUES
(1,1,'example.com','NS','ns1.pdns',1500,0,0,NULL,1),
(2,1,'example.com','NS','ns2.pdns',1500,0,0,NULL,1),
(3,1,'example.com','SOA','a.misconfigured.dns.server.invalid hostmaster.example.com 2023030501 10800 3600 604800 3600',1500,0,0,NULL,1);
/*!40000 ALTER TABLE `records` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `supermasters`
--
DROP TABLE IF EXISTS `supermasters`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `supermasters` (
`ip` varchar(64) NOT NULL,
`nameserver` varchar(255) NOT NULL,
`account` varchar(40) CHARACTER SET utf8mb3 NOT NULL,
PRIMARY KEY (`ip`,`nameserver`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `supermasters`
--
LOCK TABLES `supermasters` WRITE;
/*!40000 ALTER TABLE `supermasters` DISABLE KEYS */;
/*!40000 ALTER TABLE `supermasters` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `tsigkeys`
--
DROP TABLE IF EXISTS `tsigkeys`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `tsigkeys` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`algorithm` varchar(50) DEFAULT NULL,
`secret` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `namealgoindex` (`name`,`algorithm`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `tsigkeys`
--
LOCK TABLES `tsigkeys` WRITE;
/*!40000 ALTER TABLE `tsigkeys` DISABLE KEYS */;
/*!40000 ALTER TABLE `tsigkeys` ENABLE KEYS */;
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- MySQL DB Init Script Mock

View file

@ -1,92 +1 @@
# WELCOME TO SQUID 6.6
# ----------------------------
#
# This is the documentation for the Squid configuration file.
# This documentation can also be found online at:
# http://www.squid-cache.org/Doc/config/
#
# You may wish to look at the Squid home page and wiki for the
# FAQ and other documentation:
# http://www.squid-cache.org/
# https://wiki.squid-cache.org/SquidFaq
# https://wiki.squid-cache.org/ConfigExamples
#
# Example rule allowing access from your local networks.
# Adapt to list your (internal) IP networks from where browsing
# should be allowed
acl localnet src 0.0.0.1-0.255.255.255 # RFC 1122 "this" network (LAN)
acl localnet src 10.0.0.0/8 # RFC 1918 local private network (LAN)
acl localnet src 100.64.0.0/10 # RFC 6598 shared address space (CGN)
acl localnet src 169.254.0.0/16 # RFC 3927 link-local (directly plugged) machines
acl localnet src 172.0.0.0/8
acl localnet src 192.168.0.0/16 # RFC 1918 local private network (LAN)
acl localnet src fc00::/7 # RFC 4193 local private network range
acl localnet src fe80::/10 # RFC 4291 link-local (directly plugged) machines
acl SSL_ports port 443
acl Safe_ports port 80 # http
acl Safe_ports port 81
acl Safe_ports port 443 # https
#
# Recommended minimum Access Permission configuration:
#
# Deny requests to certain unsafe ports
http_access deny !Safe_ports
# Deny CONNECT to other than secure SSL ports
http_access deny CONNECT !SSL_ports
# Only allow cachemgr access from localhost
http_access allow localhost manager
http_access deny manager
# This default configuration only allows localhost requests because a more
# permissive Squid installation could introduce new attack vectors into the
# network by proxying external TCP connections to unprotected services.
http_access allow localhost
# The two deny rules below are unnecessary in this default configuration
# because they are followed by a "deny all" rule. However, they may become
# critically important when you start allowing external requests below them.
# Protect web applications running on the same server as Squid. They often
# assume that only local users can access them at "localhost" ports.
http_access deny to_localhost
# Protect cloud servers that provide local users with sensitive info about
# their server via certain well-known link-local (a.k.a. APIPA) addresses.
http_access deny to_linklocal
#
# INSERT YOUR OWN RULE(S) HERE TO ALLOW ACCESS FROM YOUR CLIENTS
#
include /etc/squid/conf.d/*.conf
# For example, to allow access from your local networks, you may uncomment the
# following rule (and/or add rules that match your definition of "local"):
# http_access allow localnet
# And finally deny all other access to this proxy
http_access deny all
# Squid normally listens to port 3128
http_port 3128
# Leave coredumps in the first cache dir
coredump_dir /var/spool/squid
#
# Add any of your own refresh_pattern entries above these.
#
refresh_pattern ^ftp: 1440 20% 10080
refresh_pattern -i (/cgi-bin/|\?) 0 0% 0
refresh_pattern \/(Packages|Sources)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims
refresh_pattern \/Release(|\.gpg)$ 0 0% 0 refresh-ims
refresh_pattern \/InRelease$ 0 0% 0 refresh-ims
refresh_pattern \/(Translation-.*)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims
# example pattern for deb packages
#refresh_pattern (\.deb|\.udeb)$ 129600 100% 129600
refresh_pattern . 0 20% 4320
# Squid config mock

View file

@ -1,22 +1,22 @@
import { defineConfig, type DefaultTheme } from 'vitepress';
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "Nginx Proxy Manager",
title: "xGat3",
description: "Expose your services easily and securely",
head: [
["link", { rel: "icon", href: "/icon.png" }],
["meta", { name: "description", content: "Docker container and built in Web Application for managing Nginx proxy hosts with a simple, powerful interface, providing free SSL support via Let's Encrypt" }],
["meta", { property: "og:title", content: "Nginx Proxy Manager" }],
["meta", { property: "og:title", content: "xGat3" }],
["meta", { property: "og:description", content: "Docker container and built in Web Application for managing Nginx proxy hosts with a simple, powerful interface, providing free SSL support via Let's Encrypt"}],
["meta", { property: "og:type", content: "website" }],
["meta", { property: "og:url", content: "https://nginxproxymanager.com/" }],
["meta", { property: "og:image", content: "https://nginxproxymanager.com/icon.png" }],
["meta", { property: "og:url", content: "https://x.d3v.ac/" }],
["meta", { property: "og:image", content: "https://x.d3v.ac/icon.png" }],
["meta", { name: "twitter:card", content: "summary"}],
["meta", { name: "twitter:title", content: "Nginx Proxy Manager"}],
["meta", { name: "twitter:title", content: "xGat3"}],
["meta", { name: "twitter:description", content: "Docker container and built in Web Application for managing Nginx proxy hosts with a simple, powerful interface, providing free SSL support via Let's Encrypt"}],
["meta", { name: "twitter:image", content: "https://nginxproxymanager.com/icon.png"}],
["meta", { name: "twitter:alt", content: "Nginx Proxy Manager"}],
["meta", { name: "twitter:image", content: "https://x.d3v.ac/icon.png"}],
["meta", { name: "twitter:alt", content: "xGat3"}],
// GA
['script', { async: 'true', src: 'https://www.googletagmanager.com/gtag/js?id=G-TXT8F5WY5B'}],
['script', {}, "window.dataLayer = window.dataLayer || [];\nfunction gtag(){dataLayer.push(arguments);}\ngtag('js', new Date());\ngtag('config', 'G-TXT8F5WY5B');"],

View file

@ -50,7 +50,7 @@ I won't go in to too much detail here but here are the basics for someone new to
1. Your home router will have a Port Forwarding section somewhere. Log in and find it
2. Add port forwarding for port 80 and 443 to the server hosting this project
3. Configure your domain name details to point to your home, either with a static ip or a service like DuckDNS or [Amazon Route53](https://github.com/jc21/route53-ddns)
4. Use the Nginx Proxy Manager as your gateway to forward to your other web based services
4. Use the xGat3 as your gateway to forward to your other web based services
## Quick Setup
@ -77,7 +77,7 @@ services:
- ./letsencrypt:/etc/letsencrypt
```
This is the bare minimum configuration required. See the [documentation](https://nginxproxymanager.com/setup/) for more.
This is the bare minimum configuration required. See the [documentation](https://x.d3v.ac/setup/) for more.
3. Bring up your stack by running

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nginx Proxy Manager</title>
<title>xGat3</title>
<meta name="description" content="Manage Nginx hosts with a simple, powerful interface" />
<link rel="preload" href="/images/logo-no-text.svg" as="image" type="image/svg+xml" fetchPriority="high">
<link

View file

@ -25,6 +25,7 @@
"react": "^19.2.4",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"react-intl": "^8.1.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
@ -4904,6 +4905,22 @@
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
"license": "MIT"
},
"node_modules/react-hook-form": {
"version": "7.71.2",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-intl": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-8.1.3.tgz",
@ -6210,22 +6227,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"extraneous": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",

View file

@ -32,6 +32,7 @@
"react": "^19.2.4",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"react-intl": "^8.1.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",

View file

@ -26,9 +26,24 @@ const RedirectionHosts = lazy(() => import("src/pages/Nginx/RedirectionHosts"));
const DeadHosts = lazy(() => import("src/pages/Nginx/DeadHosts"));
const Streams = lazy(() => import("src/pages/Nginx/Streams"));
const WireGuard = lazy(() => import("src/pages/WireGuard"));
const DatabaseManager = lazy(() => import("src/pages/DatabaseManager"));
const WgPublicPortal = lazy(() => import("src/pages/WgPublicPortal"));
function Router() {
const health = useHealth();
const isPublicWg = window.location.pathname.startsWith("/wg-public");
if (isPublicWg) {
return (
<BrowserRouter>
<Suspense fallback={<LoadingPage />}>
<Routes>
<Route path="/wg-public/*" element={<WgPublicPortal />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
const { authenticated } = useAuthState();
if (health.isLoading) {
@ -73,6 +88,7 @@ function Router() {
<Route path="/nginx/stream" element={<Streams />} />
<Route path="/" element={<Dashboard />} />
<Route path="/wireguard" element={<WireGuard />} />
<Route path="/database" element={<DatabaseManager />} />
</Routes>
</Suspense>
</SiteContainer>

View file

@ -0,0 +1,14 @@
import * as api from "./base";
export async function getTables(): Promise<string[]> {
return await api.get({
url: "/database/tables",
});
}
export async function getTableData(tableName: string, offset: number = 0, limit: number = 50): Promise<any> {
return await api.get({
url: `/database/tables/${tableName}`,
params: { offset, limit },
});
}

View file

@ -62,3 +62,4 @@ export * from "./uploadCertificate";
export * from "./validateCertificate";
export * from "./twoFactor";
export * from "./wireguard";
export * from "./database";

View file

@ -11,10 +11,16 @@ export interface WgClient {
createdOn: string;
updatedOn: string;
expiresAt: string | null;
interfaceId: number;
interfaceName: string;
latestHandshakeAt: string | null;
endpoint: string | null;
transferRx: number;
transferTx: number;
txLimit: number;
rxLimit: number;
storageLimitMb: number;
storageUsageBytes?: number;
}
export interface WgInterface {
@ -25,21 +31,45 @@ export interface WgInterface {
listenPort: number;
mtu: number;
dns: string;
host: string;
host: string | null;
isolateClients: boolean;
linkedServers: number[];
storageUsageBytes?: number;
clientCount?: number;
}
export async function getWgClients(): Promise<WgClient[]> {
return await api.get({ url: "/wireguard/client" });
}
export async function getWgInterface(): Promise<WgInterface> {
export async function getWgInterfaces(): Promise<WgInterface[]> {
return await api.get({ url: "/wireguard" });
}
export async function createWgClient(data: { name: string }): Promise<WgClient> {
export async function createWgInterface(data: { mtu?: number; dns?: string; host?: string; isolate_clients?: boolean; linked_servers?: number[] }): Promise<WgInterface> {
return await api.post({ url: "/wireguard", data });
}
export async function updateWgInterface(id: number, data: { mtu?: number; dns?: string; host?: string; isolate_clients?: boolean; linked_servers?: number[] }): Promise<WgInterface> {
return await api.put({ url: `/wireguard/${id}`, data });
}
export async function deleteWgInterface(id: number): Promise<boolean> {
return await api.del({ url: `/wireguard/${id}` });
}
export async function updateWgInterfaceLinks(id: number, data: { linked_servers: number[] }): Promise<WgInterface> {
return await api.post({ url: `/wireguard/${id}/links`, data });
}
export async function createWgClient(data: { name: string; interface_id?: number; tx_limit?: number; rx_limit?: number; storage_limit_mb?: number; }): Promise<WgClient> {
return await api.post({ url: "/wireguard/client", data });
}
export async function updateWgClient(id: number, data: { name?: string; allowed_ips?: string; persistent_keepalive?: number; expires_at?: string; tx_limit?: number; rx_limit?: number; storage_limit_mb?: number; }): Promise<WgClient> {
return await api.put({ url: `/wireguard/client/${id}`, data });
}
export async function deleteWgClient(id: number): Promise<boolean> {
return await api.del({ url: `/wireguard/client/${id}` });
}
@ -59,3 +89,41 @@ export async function getWgClientConfig(id: number): Promise<string> {
export function downloadWgConfig(id: number, name: string) {
return api.download({ url: `/wireguard/client/${id}/configuration` }, `${name}.conf`);
}
export function downloadWgConfigZip(id: number, name: string) {
return api.download({ url: `/wireguard/client/${id}/configuration.zip` }, `${name}.zip`);
}
export async function getWgClientFiles(id: number): Promise<any[]> {
return await api.get({ url: `/wireguard/client/${id}/files` });
}
export async function getWgClientStorage(id: number): Promise<{ totalBytes: number; limitMb: number }> {
return await api.get({ url: `/wireguard/client/${id}/storage` });
}
export async function getWgDashboardStats(): Promise<any> {
return await api.get({ url: `/wireguard/dashboard` });
}
export async function getWgClientLogs(id: number): Promise<any[]> {
return await api.get({ url: `/wireguard/client/${id}/logs` });
}
export async function uploadWgClientFile(id: number, file: File): Promise<any> {
const formData = new FormData();
formData.append("file", file);
return await api.post({
url: `/wireguard/client/${id}/files`,
data: formData
});
}
export function downloadWgClientFile(id: number, filename: string) {
return api.download({ url: `/wireguard/client/${id}/files/${encodeURIComponent(filename)}` }, filename);
}
export async function deleteWgClientFile(id: number, filename: string): Promise<boolean> {
return await api.del({ url: `/wireguard/client/${id}/files/${encodeURIComponent(filename)}` });
}

View file

@ -1,74 +1,75 @@
import { useCheckVersion, useHealth } from "src/hooks";
import { T } from "src/locale";
import { useEffect, useState } from "react";
import { IconCpu, IconServer, IconArrowsDownUp, IconDatabase } from "@tabler/icons-react";
import * as api from "../api/backend/base";
export function SiteFooter() {
const health = useHealth();
const { data: versionData } = useCheckVersion();
const [sysStats, setSysStats] = useState({
cpu: 0,
memory: 0,
memoryTotal: 0,
memoryActive: 0,
storage: 0,
storageTotal: 0,
storageUsed: 0,
networkRx: "0.00",
networkTx: "0.00"
});
const getVersion = () => {
if (!health.data) {
return "";
}
const v = health.data.version;
return `v${v.major}.${v.minor}.${v.revision}`;
};
useEffect(() => {
let isMounted = true;
const fetchStats = async () => {
try {
const data = await api.get({ url: "/reports/system" });
if (isMounted && data) {
setSysStats(data);
}
} catch (err) {
// Silently fail polling to prevent console flood
}
};
// Initial fetch
fetchStats();
// Poll every 1 second
const interval = setInterval(fetchStats, 1000);
return () => {
isMounted = false;
clearInterval(interval);
};
}, []);
// Convert bytes to GB string
const formatGB = (bytes: number) => (bytes / 1024 / 1024 / 1024).toFixed(1);
return (
<footer className="footer d-print-none py-3">
<div className="container-xl">
<div className="row text-center align-items-center flex-row-reverse">
<div className="col-lg-auto ms-lg-auto">
<ul className="list-inline list-inline-dots mb-0">
<li className="list-inline-item">
<a
href="https://github.com/NginxProxyManager/nginx-proxy-manager"
target="_blank"
className="link-secondary"
rel="noopener"
>
<T id="footer.github-fork" />
</a>
</li>
</ul>
<div className="col-lg-auto ms-lg-auto d-flex gap-3 align-items-center text-muted small">
<div title="CPU Usage" className="d-flex align-items-center gap-1">
<IconCpu size={16} />
<span>{sysStats.cpu}%</span>
</div>
<div title={`Memory Usage (${formatGB(sysStats.memoryActive)}GB / ${formatGB(sysStats.memoryTotal)}GB)`} className="d-flex align-items-center gap-1">
<IconServer size={16} />
<span>{sysStats.memory}% of {Math.round(sysStats.memoryTotal / 1024 / 1024 / 1024)}GB</span>
</div>
<div title={`Storage Usage (${formatGB(sysStats.storageUsed)}GB / ${formatGB(sysStats.storageTotal)}GB)`} className="d-flex align-items-center gap-1">
<IconDatabase size={16} />
<span>Free {formatGB(sysStats.storageTotal - sysStats.storageUsed)}GB</span>
</div>
<div title="Network Bandwidth" className="d-flex align-items-center gap-1">
<IconArrowsDownUp size={16} />
<span>{sysStats.networkRx} {sysStats.networkTx} Mbps</span>
</div>
</div>
<div className="col-12 col-lg-auto mt-3 mt-lg-0">
<ul className="list-inline list-inline-dots mb-0">
<li className="list-inline-item">
© 2025{" "}
<a href="https://jc21.com" rel="noreferrer" target="_blank" className="link-secondary">
jc21.com
</a>
© D3V.AC 2026{" "}
</li>
<li className="list-inline-item">
Theme by{" "}
<a href="https://tabler.io" rel="noreferrer" target="_blank" className="link-secondary">
Tabler
</a>
</li>
<li className="list-inline-item">
<a
href={`https://github.com/NginxProxyManager/nginx-proxy-manager/releases/tag/${getVersion()}`}
className="link-secondary"
target="_blank"
rel="noopener"
>
{" "}
{getVersion()}{" "}
</a>
</li>
{versionData?.updateAvailable && versionData?.latest && (
<li className="list-inline-item">
<a
href={`https://github.com/NginxProxyManager/nginx-proxy-manager/releases/tag/${versionData.latest}`}
className="link-warning fw-bold"
target="_blank"
rel="noopener"
title={`New version ${versionData.latest} is available`}
>
<T id="update-available" data={{ latestVersion: versionData.latest }} />
</a>
</li>
)}
</ul>
</div>
</div>

View file

@ -36,7 +36,7 @@ export function SiteHeader() {
alt="Logo"
/>
</div>
Nginx Proxy Manager
xGat3
</NavLink>
</div>
<div className="navbar-nav flex-row order-md-last">

View file

@ -102,10 +102,23 @@ const menuItems: MenuItem[] = [
permissionSection: ADMIN,
},
{
to: "/settings",
icon: IconSettings,
label: "settings",
label: "tools",
permissionSection: ADMIN,
items: [
{
to: "/settings",
label: "settings",
permissionSection: ADMIN,
permission: VIEW,
},
{
to: "/database",
label: "database-manager",
permissionSection: ADMIN,
permission: VIEW,
}
],
},
];

View file

@ -1,4 +1,4 @@
import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconLock, IconShield, IconUser } from "@tabler/icons-react";
import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconLock, IconNetwork, IconServer, IconShield, IconUser } from "@tabler/icons-react";
import cn from "classnames";
import type { AuditLog } from "src/api/backend";
import { useLocaleState } from "src/context";
@ -17,6 +17,12 @@ const getEventValue = (event: AuditLog) => {
return event.meta?.incomingPort || "N/A";
case "certificate":
return event.meta?.domainNames?.join(", ") || event.meta?.niceName || "N/A";
case "wireguard-server":
return event.meta?.name || `Server #${event.objectId}`;
case "wireguard-client":
return event.meta?.name || `Client #${event.objectId}`;
case "wireguard-server-links":
return `Server #${event.objectId}`;
default:
return `UNKNOWN EVENT TYPE: ${event.objectType}`;
}
@ -58,6 +64,13 @@ const getIcon = (row: AuditLog) => {
case "certificate":
ico = <IconShield size={16} className={c} />;
break;
case "wireguard-server":
case "wireguard-server-links":
ico = <IconServer size={16} className={c} />;
break;
case "wireguard-client":
ico = <IconNetwork size={16} className={c} />;
break;
}
return ico;

View file

@ -1,8 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getWgClients,
getWgInterface,
getWgInterfaces,
createWgInterface,
updateWgInterface,
deleteWgInterface,
updateWgInterfaceLinks,
createWgClient,
updateWgClient,
deleteWgClient,
enableWgClient,
disableWgClient,
@ -20,19 +25,72 @@ export const useWgClients = (options = {}) => {
});
};
export const useWgInterface = (options = {}) => {
return useQuery<WgInterface, Error>({
queryKey: ["wg-interface"],
queryFn: getWgInterface,
staleTime: 60 * 1000,
export const useWgInterfaces = (options = {}) => {
return useQuery<WgInterface[], Error>({
queryKey: ["wg-interfaces"],
queryFn: getWgInterfaces,
refetchInterval: 10000,
staleTime: 5000,
...options,
});
};
export const useCreateWgInterface = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { mtu?: number; dns?: string; host?: string; isolate_clients?: boolean; linked_servers?: number[] }) => createWgInterface(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wg-interfaces"] });
},
});
};
export const useUpdateWgInterface = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: { mtu?: number; dns?: string; host?: string; isolate_clients?: boolean; linked_servers?: number[] } }) => updateWgInterface(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wg-interfaces"] });
},
});
};
export const useDeleteWgInterface = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteWgInterface(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wg-interfaces"] });
queryClient.invalidateQueries({ queryKey: ["wg-clients"] });
},
});
};
export const useUpdateWgInterfaceLinks = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: { linked_servers: number[] } }) => updateWgInterfaceLinks(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wg-interfaces"] });
},
});
};
export const useCreateWgClient = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name: string }) => createWgClient(data),
mutationFn: (data: { name: string; interface_id?: number; tx_limit?: number; rx_limit?: number; storage_limit_mb?: number; }) => createWgClient(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wg-clients"] });
},
});
};
export const useUpdateWgClient = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: any }) => updateWgClient(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wg-clients"] });
},

View file

@ -1,50 +0,0 @@
# Internationalisation support
## Before you start
It's highly recommended that you spin up a development instance of this project
on your docker capable server. It's pretty easy:
```bash
git clone https://github.com/NginxProxyManager/nginx-proxy-manager.git
cd nginx-proxy-manager
./scripts/start-dev -f
```
Then after a while, you can access http://yourserverip:3081
This stack will watch the file system for changes, especially to language files,
and reload the site you have open in the browser.
## Adding new translations
Modify the files in the `src` folder. Follow the conventions already there.
When the development stack is running, it will sort the locale lang files
for you when you save.
## After making changes
If you're NOT running the development stack, you will need to run
`yarn locale-compile` in the `frontend` folder for
the new translations to be compiled into the `lang` folder.
## Adding a whole new language
There's a fair bit you'll need to touch. Here's a list that may
not be complete by the time you're reading this:
- frontend/src/locale/src/[yourlang].json
- frontend/src/locale/src/lang-list.json
- frontend/src/locale/src/HelpDoc/[yourlang]/*
- frontend/src/locale/src/HelpDoc/index.tsx
- frontend/src/locale/IntlProvider.tsx
- frontend/check-locales.cjs
## Checking for missing translations in languages
Run `node check-locales.cjs` in this frontend folder.

View file

@ -4,4 +4,4 @@
Той предоставя възможност за SSL терминaция на услуга, която може да няма вградена поддръжка на SSL.
Прокси хостовете са най-често използваната функция в Nginx Proxy Manager.
Прокси хостовете са най-често използваната функция в xGat3.

View file

@ -4,4 +4,4 @@ Proxy hostitel je příchozí koncový bod pro webovou službu, kterou chcete p
Poskytuje volitelné ukončení SSL pro vaši službu, která nemusí mít zabudovanou podporu SSL.
Proxy hostitelé jsou nejběžnějším použitím pro Nginx Proxy Manager.
Proxy hostitelé jsou nejběžnějším použitím pro xGat3.

View file

@ -4,4 +4,4 @@ Ein Proxy-Host ist der eingehende Endpunkt für einen Webdienst, den Sie weiterl
Er bietet optionale SSL-Terminierung für Ihren Dienst, der möglicherweise keine integrierte SSL-Unterstützung hat.
Proxy-Hosts sind die häufigste Verwendung für den Nginx Proxy Manager.
Proxy-Hosts sind die häufigste Verwendung für den xGat3.

View file

@ -4,4 +4,4 @@ A Proxy Host is the incoming endpoint for a web service that you want to forward
It provides optional SSL termination for your service that might not have SSL support built in.
Proxy Hosts are the most common use for the Nginx Proxy Manager.
Proxy Hosts are the most common use for the xGat3.

View file

@ -4,4 +4,4 @@ Un Host Proxy es el punto de entrada para un servicio web que deseas reenviar.
Proporciona terminación SSL opcional para tu servicio que podría no tener soporte SSL integrado.
Los Hosts Proxy son el uso más común del Nginx Proxy Manager.
Los Hosts Proxy son el uso más común del xGat3.

View file

@ -4,4 +4,4 @@ Un Hôte Proxy est le point de terminaison entrant d'un service web que vous sou
Il assure la terminaison SSL optionnelle pour votre service qui ne prend pas en charge SSL nativement.
Les Hôtes Proxy constituent l'utilisation la plus courante du Nginx Proxy Manager.
Les Hôtes Proxy constituent l'utilisation la plus courante du xGat3.

View file

@ -4,4 +4,4 @@ A Proxy Kiszolgáló egy bejövő végpont egy olyan webszolgáltatáshoz, amely
Opcionális SSL lezárást biztosít a szolgáltatásodhoz, amelyben esetleg nincs beépített SSL támogatás.
A Proxy Kiszolgálók az Nginx Proxy Manager leggyakoribb felhasználási módjai.
A Proxy Kiszolgálók az xGat3 leggyakoribb felhasználási módjai.

View file

@ -4,4 +4,4 @@ Host Proxy adalah endpoint masuk untuk layanan web yang ingin Anda teruskan.
Host ini menyediakan terminasi SSL opsional untuk layanan Anda yang mungkin tidak memiliki dukungan SSL bawaan.
Host Proxy adalah penggunaan paling umum untuk Nginx Proxy Manager.
Host Proxy adalah penggunaan paling umum untuk xGat3.

View file

@ -4,4 +4,4 @@ Un host proxy è l'endpoint in entrata per un servizio web che si desidera inolt
Fornisce la terminazione SSL opzionale per il servizio che potrebbe non avere il supporto SSL integrato.
Gli host proxy sono l'uso più comune per Nginx Proxy Manager.
Gli host proxy sono l'uso più comune per xGat3.

View file

@ -4,4 +4,4 @@
サービスにSSLサーバーが組み込まれていない場合でも、オプションでSSL終端機能を提供します。
プロキシホストはNginx Proxy Managerのもっとも一般的な使用方法です。
プロキシホストはxGat3のもっとも一般的な使用方法です。

View file

@ -4,7 +4,7 @@
HTTP 검증 방식의 인증서는 Let's Encrypt 서버가 **HTTPS가 아닌 HTTP로** 해당 도메인에 접속을 시도해 응답이 확인되면 인증서를 발급하는 방식입니다.
이 방식을 사용하려면 도메인에 대한 **프록시 호스트가 미리 생성되어 있어야 하며**, HTTP로 접근할 수 있어야 하고 Nginx Proxy Manager가 설치된 서버를 가리켜야 합니다. 인증서가 발급된 이후에는 해당 프록시 호스트에 HTTPS용 인증서를 적용할 수 있습니다.
이 방식을 사용하려면 도메인에 대한 **프록시 호스트가 미리 생성되어 있어야 하며**, HTTP로 접근할 수 있어야 하고 xGat3가 설치된 서버를 가리켜야 합니다. 인증서가 발급된 이후에는 해당 프록시 호스트에 HTTPS용 인증서를 적용할 수 있습니다.
다만, **인증서 자동 갱신을 위해서는 HTTP 접근이 계속 필요합니다.**

View file

@ -4,5 +4,5 @@
원래 SSL을 지원하지 않는 대상이라도, 프록시 호스트를 통해 SSL(HTTPS) 연결을 적용할 수 있습니다.
프록시 호스트는 Nginx Proxy Manager에서 가장 일반적으로 사용되는 기능입니다.
프록시 호스트는 xGat3에서 가장 일반적으로 사용되는 기능입니다.

View file

@ -4,4 +4,4 @@ Een Proxy Host is de inkomende endpoint voor een webdienst dat je wilt doorsture
Het biedt optionele SSL voor je dienst die mogelijk geen SSL ondersteuning heeft.
Proxy Hosts worden het meest gebruikt in Nginx Proxy Manager.
Proxy Hosts worden het meest gebruikt in xGat3.

View file

@ -4,4 +4,4 @@ En Proxyhost er inngangspunktet (innkommende endepunkt) for en webtjeneste du
Den tilbyr valgfri SSLterminering for tjenesten din hvis tjenesten ikke har innebygd støtte for SSL.
Proxyhosts er den vanligste bruken av Nginx Proxy Manager.
Proxyhosts er den vanligste bruken av xGat3.

View file

@ -4,4 +4,4 @@ Host proxy to punkt wejściowy dla usługi internetowej, którą chcesz przekier
Zapewnia opcjonalne zakończenie SSL dla twojej usługi, która może nie mieć wbudowanej obsługi SSL.
Hosty proxy są najpopularniejszym zastosowaniem Nginx Proxy Manager
Hosty proxy są najpopularniejszym zastosowaniem xGat3

View file

@ -4,4 +4,4 @@ Um *Proxy Host* é o ponto de entrada para um serviço web que pretendes encamin
Permite, opcionalmente, fazer terminação SSL para um serviço que possa não ter suporte SSL nativo.
Os *Proxy Hosts* são a utilização mais comum do Nginx Proxy Manager.
Os *Proxy Hosts* são a utilização mais comum do xGat3.

View file

@ -4,4 +4,4 @@
Он может выполнять терминaцию SSL для сервиса, у которого нет собственной поддержки SSL.
Прокси‑хосты — самый распространённый сценарий использования Nginx Proxy Manager.
Прокси‑хосты — самый распространённый сценарий использования xGat3.

View file

@ -4,4 +4,4 @@ Proxy hostiteľ je prichádzajúci koncový bod pre webovú službu, ktorú chce
Poskytuje voliteľné ukončenie SSL pre vašu službu, ktorá nemusí mať zabudovanú podporu SSL.
Proxy hostitelia sú najbežnejším použitím pre Nginx Proxy Manager.
Proxy hostitelia sú najbežnejším použitím pre xGat3.

View file

@ -4,5 +4,5 @@ Proxy Host, iletilmek istediğiniz bir web hizmeti için gelen uç noktadır.
SSL desteği yerleşik olmayan hizmetiniz için isteğe bağlı SSL sonlandırma sağlar.
Proxy Host'lar, Nginx Proxy Manager'ın en yaygın kullanımıdır.
Proxy Host'lar, xGat3'ın en yaygın kullanımıdır.

View file

@ -4,4 +4,4 @@ Proxy Host là điểm truy cập đầu vào cho một dịch vụ web mà bạ
Nó cung cấp khả năng kết thúc SSL (SSL termination) tùy chọn cho các dịch vụ vốn không hỗ trợ SSL tích hợp.
Proxy Host là loại cấu hình phổ biến nhất trong Nginx Proxy Manager.
Proxy Host là loại cấu hình phổ biến nhất trong xGat3.

View file

@ -147,7 +147,7 @@
"defaultMessage": "Този раздел изисква познания за Certbot и неговите DNS плъгини. Моля, консултирайте се с документацията."
},
"certificates.http.reachability-404": {
"defaultMessage": "Сървър е намерен на този домейн, но не изглежда да е Nginx Proxy Manager. Уверете се, че домейнът сочи към IP адреса, където работи NPM."
"defaultMessage": "Сървър е намерен на този домейн, но не изглежда да е xGat3. Уверете се, че домейнът сочи към IP адреса, където работи NPM."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Неуспешна проверка поради грешка в комуникацията със site24x7.com."
@ -691,5 +691,26 @@
},
"users": {
"defaultMessage": "Потребители"
},
"wireguard-client": {
"defaultMessage": "WireGuard Клиент"
},
"wireguard-clients": {
"defaultMessage": "WireGuard Клиенти"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Клиент} other {WireGuard Клиента}}"
},
"wireguard-server": {
"defaultMessage": "WireGuard Сървър"
},
"wireguard-server-links": {
"defaultMessage": "WireGuard Сървърни Връзки"
},
"wireguard-servers": {
"defaultMessage": "WireGuard Сървъри"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Сървър} other {WireGuard Сървъра}}"
}
}

View file

@ -204,7 +204,7 @@
"defaultMessage": "Tato sekce vyžaduje znalost Certbotu a jeho DNS doplňků. Prosím, podívejte se do dokumentace příslušného doplňku."
},
"certificates.http.reachability-404": {
"defaultMessage": "Na této doméně byl nalezen server, ale nezdá se, že jde o Nginx Proxy Manager. Ujistěte se, že vaše doména směřuje na IP, kde běží vaše instance NPM."
"defaultMessage": "Na této doméně byl nalezen server, ale nezdá se, že jde o xGat3. Ujistěte se, že vaše doména směřuje na IP, kde běží vaše instance NPM."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Nepodařilo se ověřit dostupnost kvůli chybě komunikace se službou site24x7.com."
@ -766,5 +766,26 @@
},
"users": {
"defaultMessage": "Uživatelé"
},
"wireguard-client": {
"defaultMessage": "WireGuard Klient"
},
"wireguard-clients": {
"defaultMessage": "WireGuard Klienti"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Klient} other {WireGuard Klientů}}"
},
"wireguard-server": {
"defaultMessage": "WireGuard Server"
},
"wireguard-server-links": {
"defaultMessage": "WireGuard Propojení Serverů"
},
"wireguard-servers": {
"defaultMessage": "WireGuard Servery"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Server} other {WireGuard Serverů}}"
}
}

View file

@ -132,7 +132,7 @@
"defaultMessage": "Dieser Abschnitt erfordert einige Kenntnisse über Certbot und seine DNS-Plugins. Bitte konsultieren Sie die jeweilige Plugin-Dokumentation."
},
"certificates.http.reachability-404": {
"defaultMessage": "Unter dieser Domain wurde ein Server gefunden, aber es scheint sich nicht um Nginx Proxy Manager zu handeln. Bitte stellen Sie sicher, dass Ihre Domain auf die IP-Adresse verweist, unter der Ihre NPM-Instanz ausgeführt wird."
"defaultMessage": "Unter dieser Domain wurde ein Server gefunden, aber es scheint sich nicht um xGat3 zu handeln. Bitte stellen Sie sicher, dass Ihre Domain auf die IP-Adresse verweist, unter der Ihre NPM-Instanz ausgeführt wird."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Die Erreichbarkeit konnte aufgrund eines Kommunikationsfehlers mit site24x7.com nicht überprüft werden."
@ -652,5 +652,26 @@
},
"users": {
"defaultMessage": "Benutzer"
},
"wireguard-client": {
"defaultMessage": "WireGuard Client"
},
"wireguard-clients": {
"defaultMessage": "WireGuard Clients"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Client} other {WireGuard Clients}}"
},
"wireguard-server": {
"defaultMessage": "WireGuard Server"
},
"wireguard-server-links": {
"defaultMessage": "WireGuard Server-Verknüpfungen"
},
"wireguard-servers": {
"defaultMessage": "WireGuard Server"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Server} other {WireGuard Server}}"
}
}

View file

@ -204,7 +204,7 @@
"defaultMessage": "This section requires some knowledge about Certbot and its DNS plugins. Please consult the respective plugins documentation."
},
"certificates.http.reachability-404": {
"defaultMessage": "There is a server found at this domain but it does not seem to be Nginx Proxy Manager. Please make sure your domain points to the IP where your NPM instance is running."
"defaultMessage": "There is a server found at this domain but it does not seem to be xGat3. Please make sure your domain points to the IP where your NPM instance is running."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Failed to check the reachability due to a communication error with site24x7.com."
@ -775,5 +775,26 @@
},
"wireguard": {
"defaultMessage": "WireGuard"
},
"wireguard-client": {
"defaultMessage": "WireGuard Client"
},
"wireguard-clients": {
"defaultMessage": "WireGuard Clients"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Client} other {WireGuard Clients}}"
},
"wireguard-server": {
"defaultMessage": "WireGuard Server"
},
"wireguard-server-links": {
"defaultMessage": "WireGuard Server Links"
},
"wireguard-servers": {
"defaultMessage": "WireGuard Servers"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Server} other {WireGuard Servers}}"
}
}

View file

@ -147,7 +147,7 @@
"defaultMessage": "Esta sección requiere algunos conocimientos sobre Certbot y sus plugins DNS. Consulta la documentación de los plugins respectivos."
},
"certificates.http.reachability-404": {
"defaultMessage": "Se encontró un servidor en este dominio pero no parece ser Nginx Proxy Manager. Asegúrate de que tu dominio apunte a la IP donde se está ejecutando tu instancia de NPM."
"defaultMessage": "Se encontró un servidor en este dominio pero no parece ser xGat3. Asegúrate de que tu dominio apunte a la IP donde se está ejecutando tu instancia de NPM."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "No se pudo verificar la accesibilidad debido a un error de comunicación con site24x7.com."
@ -688,5 +688,26 @@
},
"users": {
"defaultMessage": "Usuarios"
},
"wireguard-client": {
"defaultMessage": "Cliente WireGuard"
},
"wireguard-clients": {
"defaultMessage": "Clientes WireGuard"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {Cliente WireGuard} other {Clientes WireGuard}}"
},
"wireguard-server": {
"defaultMessage": "Servidor WireGuard"
},
"wireguard-server-links": {
"defaultMessage": "Enlaces de servidor WireGuard"
},
"wireguard-servers": {
"defaultMessage": "Servidores WireGuard"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {Servidor WireGuard} other {Servidores WireGuard}}"
}
}

View file

@ -204,7 +204,7 @@
"defaultMessage": "This section requires some knowledge about Certbot and its DNS plugins. Please consult the respective plugins documentation."
},
"certificates.http.reachability-404": {
"defaultMessage": "There is a server found at this domain but it does not seem to be Nginx Proxy Manager. Please make sure your domain points to the IP where your NPM instance is running."
"defaultMessage": "There is a server found at this domain but it does not seem to be xGat3. Please make sure your domain points to the IP where your NPM instance is running."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Failed to check the reachability due to a communication error with site24x7.com."
@ -772,5 +772,26 @@
},
"users": {
"defaultMessage": "Users"
},
"wireguard-client": {
"defaultMessage": "WireGuard Klient"
},
"wireguard-clients": {
"defaultMessage": "WireGuard Kliendid"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Klient} other {WireGuard Klienti}}"
},
"wireguard-server": {
"defaultMessage": "WireGuard Server"
},
"wireguard-server-links": {
"defaultMessage": "WireGuard Serveri Lingid"
},
"wireguard-servers": {
"defaultMessage": "WireGuard Serverid"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Server} other {WireGuard Serverit}}"
}
}

View file

@ -132,7 +132,7 @@
"defaultMessage": "Cette section requiert une certaine connaissance de Certbot et de ses plugins DNS. Veuillez consulter la documentation des plugins correspondants."
},
"certificates.http.reachability-404": {
"defaultMessage": "Un serveur a été trouvé sur ce domaine, mais il ne semble pas s'agir de Nginx Proxy Manager. Veuillez vérifier que votre domaine pointe bien vers l'adresse IP où votre instance NPM est exécutée."
"defaultMessage": "Un serveur a été trouvé sur ce domaine, mais il ne semble pas s'agir de xGat3. Veuillez vérifier que votre domaine pointe bien vers l'adresse IP où votre instance NPM est exécutée."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Impossible de vérifier l'accessibilité en raison d'une erreur de communication avec site24x7.com."
@ -643,5 +643,26 @@
},
"users": {
"defaultMessage": "Utilisateurs"
},
"wireguard-client": {
"defaultMessage": "Client WireGuard"
},
"wireguard-clients": {
"defaultMessage": "Clients WireGuard"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {Client WireGuard} other {Clients WireGuard}}"
},
"wireguard-server": {
"defaultMessage": "Serveur WireGuard"
},
"wireguard-server-links": {
"defaultMessage": "Liens serveur WireGuard"
},
"wireguard-servers": {
"defaultMessage": "Serveurs WireGuard"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {Serveur WireGuard} other {Serveurs WireGuard}}"
}
}

View file

@ -679,5 +679,26 @@
},
"users": {
"defaultMessage": "Úsáideoirí"
},
"wireguard-client": {
"defaultMessage": "Cliant WireGuard"
},
"wireguard-clients": {
"defaultMessage": "Cliaint WireGuard"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {Cliant WireGuard} other {Cliant WireGuard}}"
},
"wireguard-server": {
"defaultMessage": "Freastalaí WireGuard"
},
"wireguard-server-links": {
"defaultMessage": "Naisc Fhreastalaí WireGuard"
},
"wireguard-servers": {
"defaultMessage": "Freastalaithe WireGuard"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {Freastalaí WireGuard} other {Freastalaí WireGuard}}"
}
}

View file

@ -204,7 +204,7 @@
"defaultMessage": "Ez a szakasz némi ismeretet igényel a Certbot-ról és a DNS plugin-jeiről. Kérjük, olvassa el a megfelelő plugin dokumentációját."
},
"certificates.http.reachability-404": {
"defaultMessage": "Található szerver ezen a domain-en, de nem úgy tűnik, hogy Nginx Proxy Manager lenne. Kérjük, győződjön meg róla, hogy a domain arra az IP címre mutat, ahol az NPM példánya fut."
"defaultMessage": "Található szerver ezen a domain-en, de nem úgy tűnik, hogy xGat3 lenne. Kérjük, győződjön meg róla, hogy a domain arra az IP címre mutat, ahol az NPM példánya fut."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Az elérhetőség ellenőrzése sikertelen a site24x7.com kommunikációs hiba miatt."
@ -766,5 +766,26 @@
},
"users": {
"defaultMessage": "Felhasználók"
},
"wireguard-client": {
"defaultMessage": "WireGuard Kliens"
},
"wireguard-clients": {
"defaultMessage": "WireGuard Kliensek"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Kliens} other {WireGuard Kliensek}}"
},
"wireguard-server": {
"defaultMessage": "WireGuard Szerver"
},
"wireguard-server-links": {
"defaultMessage": "WireGuard Szerver Kapcsolatok"
},
"wireguard-servers": {
"defaultMessage": "WireGuard Szerverek"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Szerver} other {WireGuard Szerverek}}"
}
}

View file

@ -147,7 +147,7 @@
"defaultMessage": "Bagian ini memerlukan pengetahuan tentang Certbot dan plugin DNS-nya. Silakan merujuk dokumentasi plugin terkait."
},
"certificates.http.reachability-404": {
"defaultMessage": "Ada server yang ditemukan pada domain ini tetapi tampaknya bukan Nginx Proxy Manager. Pastikan domain Anda mengarah ke IP tempat instance NPM berjalan."
"defaultMessage": "Ada server yang ditemukan pada domain ini tetapi tampaknya bukan xGat3. Pastikan domain Anda mengarah ke IP tempat instance NPM berjalan."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Gagal memeriksa keterjangkauan karena kesalahan komunikasi dengan site24x7.com."
@ -679,5 +679,26 @@
},
"users": {
"defaultMessage": "Pengguna"
},
"wireguard-client": {
"defaultMessage": "Klien WireGuard"
},
"wireguard-clients": {
"defaultMessage": "Klien WireGuard"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {Klien WireGuard} other {Klien WireGuard}}"
},
"wireguard-server": {
"defaultMessage": "Server WireGuard"
},
"wireguard-server-links": {
"defaultMessage": "Tautan Server WireGuard"
},
"wireguard-servers": {
"defaultMessage": "Server WireGuard"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {Server WireGuard} other {Server WireGuard}}"
}
}

View file

@ -132,7 +132,7 @@
"defaultMessage": "Questa sezione richiede conoscenze su Certbot e i relativi plugin DNS. Consulta la documentazione del plugin."
},
"certificates.http.reachability-404": {
"defaultMessage": "È stato trovato un server su questo dominio, ma non sembra essere Nginx Proxy Manager. Assicurati che il dominio punti all'IP dove è in esecuzione NPM."
"defaultMessage": "È stato trovato un server su questo dominio, ma non sembra essere xGat3. Assicurati che il dominio punti all'IP dove è in esecuzione NPM."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Verifica di raggiungibilità fallita per errore di comunicazione con site24x7.com."
@ -655,5 +655,26 @@
},
"users": {
"defaultMessage": "Utenti"
},
"wireguard-client": {
"defaultMessage": "Client WireGuard"
},
"wireguard-clients": {
"defaultMessage": "Client WireGuard"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {Client WireGuard} other {Client WireGuard}}"
},
"wireguard-server": {
"defaultMessage": "Server WireGuard"
},
"wireguard-server-links": {
"defaultMessage": "Link server WireGuard"
},
"wireguard-servers": {
"defaultMessage": "Server WireGuard"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {Server WireGuard} other {Server WireGuard}}"
}
}

View file

@ -132,7 +132,7 @@
"defaultMessage": "このセクションはCertbotとそのDNSプラグインの知識が必要です。各プラグインのドキュメントを参照してください。"
},
"certificates.http.reachability-404": {
"defaultMessage": "このドメインはNginx Proxy Managerではないサーバーを指しているようです。ドメインがこのNPMインスタンスを指していることを確認してください。"
"defaultMessage": "このドメインはxGat3ではないサーバーを指しているようです。ドメインがこのNPMインスタンスを指していることを確認してください。"
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "site24x7.comへの接続でエラーが発生し、到達性チェックに失敗しました"
@ -649,5 +649,26 @@
},
"users": {
"defaultMessage": "ユーザー"
},
"wireguard-client": {
"defaultMessage": "WireGuard クライアント"
},
"wireguard-clients": {
"defaultMessage": "WireGuard クライアント"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard クライアント} other {WireGuard クライアント}}"
},
"wireguard-server": {
"defaultMessage": "WireGuard サーバー"
},
"wireguard-server-links": {
"defaultMessage": "WireGuard サーバーリンク"
},
"wireguard-servers": {
"defaultMessage": "WireGuard サーバー"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard サーバー} other {WireGuard サーバー}}"
}
}

View file

@ -147,7 +147,7 @@
"defaultMessage": "이 기능을 사용하려면 Certbot과 DNS 플러그인에 대한 기본적인 이해가 필요합니다. 자세한 내용은 관련 문서를 참고해 주세요."
},
"certificates.http.reachability-404": {
"defaultMessage": "해당 도메인에서 서버가 탐지되었지만 Nginx Proxy Manager가 아닌 것으로 보입니다. 도메인이 NPM이 실행 중인 IP를 가리키는지 확인하세요."
"defaultMessage": "해당 도메인에서 서버가 탐지되었지만 xGat3가 아닌 것으로 보입니다. 도메인이 NPM이 실행 중인 IP를 가리키는지 확인하세요."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "site24x7.com과의 통신 오류로 인해 도달 가능 여부를 확인할 수 없습니다."
@ -691,5 +691,26 @@
},
"users": {
"defaultMessage": "사용자"
},
"wireguard-client": {
"defaultMessage": "WireGuard 클라이언트"
},
"wireguard-clients": {
"defaultMessage": "WireGuard 클라이언트"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard 클라이언트} other {WireGuard 클라이언트}}"
},
"wireguard-server": {
"defaultMessage": "WireGuard 서버"
},
"wireguard-server-links": {
"defaultMessage": "WireGuard 서버 링크"
},
"wireguard-servers": {
"defaultMessage": "WireGuard 서버"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard 서버} other {WireGuard 서버}}"
}
}

View file

@ -1,68 +1,89 @@
{
"locale-en-US": {
"defaultMessage": "English"
},
"locale-es-ES": {
"defaultMessage": "Español"
},
"locale-et-EE": {
"defaultMessage": "Eesti"
},
"locale-ie-GA": {
"defaultMessage": "Gaeilge"
},
"locale-de-DE": {
"defaultMessage": "German"
},
"locale-pt-PT": {
"defaultMessage": "Português (Europeu)"
},
"locale-fr-FR": {
"defaultMessage": "Français"
},
"locale-id-ID": {
"defaultMessage": "Bahasa Indonesia"
},
"locale-ja-JP": {
"defaultMessage": "日本語"
},
"locale-ru-RU": {
"defaultMessage": "Русский"
},
"locale-sk-SK": {
"defaultMessage": "Slovenčina"
},
"locale-cs-CZ": {
"defaultMessage": "Čeština"
},
"locale-zh-CN": {
"defaultMessage": "中文"
},
"locale-pl-PL": {
"defaultMessage": "Polski"
},
"locale-it-IT": {
"defaultMessage": "Italiano"
},
"locale-vi-VN": {
"defaultMessage": "Tiếng Việt"
},
"locale-nl-NL": {
"defaultMessage": "Nederlands"
},
"locale-ko-KR": {
"defaultMessage": "한국어"
},
"locale-bg-BG": {
"defaultMessage": "Български"
},
"locale-tr-TR": {
"defaultMessage": "Türkçe"
},
"locale-hu-HU": {
"defaultMessage": "Magyar"
},
"locale-no-NO": {
"defaultMessage": "Norsk"
}
"locale-bg-BG": {
"defaultMessage": "Български"
},
"locale-cs-CZ": {
"defaultMessage": "Čeština"
},
"locale-de-DE": {
"defaultMessage": "German"
},
"locale-en-US": {
"defaultMessage": "English"
},
"locale-es-ES": {
"defaultMessage": "Español"
},
"locale-et-EE": {
"defaultMessage": "Eesti"
},
"locale-fr-FR": {
"defaultMessage": "Français"
},
"locale-hu-HU": {
"defaultMessage": "Magyar"
},
"locale-id-ID": {
"defaultMessage": "Bahasa Indonesia"
},
"locale-ie-GA": {
"defaultMessage": "Gaeilge"
},
"locale-it-IT": {
"defaultMessage": "Italiano"
},
"locale-ja-JP": {
"defaultMessage": "日本語"
},
"locale-ko-KR": {
"defaultMessage": "한국어"
},
"locale-nl-NL": {
"defaultMessage": "Nederlands"
},
"locale-no-NO": {
"defaultMessage": "Norsk"
},
"locale-pl-PL": {
"defaultMessage": "Polski"
},
"locale-pt-PT": {
"defaultMessage": "Português (Europeu)"
},
"locale-ru-RU": {
"defaultMessage": "Русский"
},
"locale-sk-SK": {
"defaultMessage": "Slovenčina"
},
"locale-tr-TR": {
"defaultMessage": "Türkçe"
},
"locale-vi-VN": {
"defaultMessage": "Tiếng Việt"
},
"locale-zh-CN": {
"defaultMessage": "中文"
},
"wireguard-client": {
"defaultMessage": "WireGuard Client"
},
"wireguard-clients": {
"defaultMessage": "WireGuard Clients"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Client} other {WireGuard Clients}}"
},
"wireguard-server": {
"defaultMessage": "WireGuard Server"
},
"wireguard-server-links": {
"defaultMessage": "WireGuard Server Links"
},
"wireguard-servers": {
"defaultMessage": "WireGuard Servers"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Server} other {WireGuard Servers}}"
}
}

View file

@ -132,7 +132,7 @@
"defaultMessage": "Deze sectie vereist wat informatie over Certbot en zijn DNS plugins. Gebruik de documentatie van de bijbehorende plugins."
},
"certificates.http.reachability-404": {
"defaultMessage": "Er is een server gevonden op deze domeinnaam, maar dat lijkt niet Nginx Proxy Manager te zijn. Zorg ervoor dat je domein naar het IP waar je NPM instance draait wijst."
"defaultMessage": "Er is een server gevonden op deze domeinnaam, maar dat lijkt niet xGat3 te zijn. Zorg ervoor dat je domein naar het IP waar je NPM instance draait wijst."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Bereikbaarheid kan niet worden bepaald door een communicatiefout met site24x7.com."
@ -655,5 +655,26 @@
},
"users": {
"defaultMessage": "Gebruikers"
},
"wireguard-client": {
"defaultMessage": "WireGuard Client"
},
"wireguard-clients": {
"defaultMessage": "WireGuard Clients"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Client} other {WireGuard Clients}}"
},
"wireguard-server": {
"defaultMessage": "WireGuard Server"
},
"wireguard-server-links": {
"defaultMessage": "WireGuard Serverkoppelingen"
},
"wireguard-servers": {
"defaultMessage": "WireGuard Servers"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Server} other {WireGuard Servers}}"
}
}

View file

@ -204,7 +204,7 @@
"defaultMessage": "Denne seksjonen krever noe kunnskap om Certbot og dets DNS-plugins. Vennligst konsulter dokumentasjonen for de respektive pluginene."
},
"certificates.http.reachability-404": {
"defaultMessage": "Det finnes en server på dette domenet, men det ser ikke ut til å være Nginx Proxy Manager. Vennligst sørg for at domenet ditt peker til IP-en der NPM-instansen kjører."
"defaultMessage": "Det finnes en server på dette domenet, men det ser ikke ut til å være xGat3. Vennligst sørg for at domenet ditt peker til IP-en der NPM-instansen kjører."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Kunne ikke sjekke tilgjengeligheten på grunn av en kommunikasjonsfeil med site24x7.com."
@ -772,5 +772,26 @@
},
"users": {
"defaultMessage": "Brukere"
},
"wireguard-client": {
"defaultMessage": "WireGuard Klient"
},
"wireguard-clients": {
"defaultMessage": "WireGuard Klienter"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Klient} other {WireGuard Klienter}}"
},
"wireguard-server": {
"defaultMessage": "WireGuard Server"
},
"wireguard-server-links": {
"defaultMessage": "WireGuard Serverforbindelser"
},
"wireguard-servers": {
"defaultMessage": "WireGuard Servere"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Server} other {WireGuard Servere}}"
}
}

View file

@ -138,7 +138,7 @@
"defaultMessage": "Ta sekcja wymaga pewnej wiedzy na temat Certbot i jego wtyczek DNS. Zapoznaj się z dokumentacją odpowiednich wtyczek."
},
"certificates.http.reachability-404": {
"defaultMessage": "Znaleziono serwer pod tą domeną, ale nie wygląda na to, że jest to Nginx Proxy Manager. Upewnij się, że twoja domena wskazuje na adres IP, gdzie działa twoja instancja NPM."
"defaultMessage": "Znaleziono serwer pod tą domeną, ale nie wygląda na to, że jest to xGat3. Upewnij się, że twoja domena wskazuje na adres IP, gdzie działa twoja instancja NPM."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Nie udało się sprawdzić dostępności z powodu błędu komunikacji z site24x7.com."
@ -658,5 +658,26 @@
},
"users": {
"defaultMessage": "Użytkownicy"
},
"wireguard-client": {
"defaultMessage": "Klient WireGuard"
},
"wireguard-clients": {
"defaultMessage": "Klienci WireGuard"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {Klient WireGuard} other {Klientów WireGuard}}"
},
"wireguard-server": {
"defaultMessage": "Serwer WireGuard"
},
"wireguard-server-links": {
"defaultMessage": "Połączenia serwerów WireGuard"
},
"wireguard-servers": {
"defaultMessage": "Serwery WireGuard"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {Serwer WireGuard} other {Serwerów WireGuard}}"
}
}

View file

@ -147,7 +147,7 @@
"defaultMessage": "Esta secção requer conhecimentos sobre o Certbot e os seus plugins DNS. Consulte a documentação dos plugins."
},
"certificates.http.reachability-404": {
"defaultMessage": "Foi encontrado um servidor neste domínio, mas não parece ser o Nginx Proxy Manager. Certifique-se de que o domínio aponta para o IP onde a sua instância está a correr."
"defaultMessage": "Foi encontrado um servidor neste domínio, mas não parece ser o xGat3. Certifique-se de que o domínio aponta para o IP onde a sua instância está a correr."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Falha ao verificar acessibilidade devido a um erro de comunicação com site24x7.com."
@ -679,5 +679,26 @@
},
"users": {
"defaultMessage": "Utilizadores"
},
"wireguard-client": {
"defaultMessage": "Cliente WireGuard"
},
"wireguard-clients": {
"defaultMessage": "Clientes WireGuard"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {Cliente WireGuard} other {Clientes WireGuard}}"
},
"wireguard-server": {
"defaultMessage": "Servidor WireGuard"
},
"wireguard-server-links": {
"defaultMessage": "Links de servidor WireGuard"
},
"wireguard-servers": {
"defaultMessage": "Servidores WireGuard"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {Servidor WireGuard} other {Servidores WireGuard}}"
}
}

View file

@ -132,7 +132,7 @@
"defaultMessage": "Этот раздел требует знаний о Certbot и его DNS-плагинах. Пожалуйста, обратитесь к документации соответствующих плагинов."
},
"certificates.http.reachability-404": {
"defaultMessage": "На этом домене найден сервер, но, похоже, это не Nginx Proxy Manager. Убедитесь, что ваш домен указывает на IP-адрес, где запущен ваш экземпляр NPM."
"defaultMessage": "На этом домене найден сервер, но, похоже, это не xGat3. Убедитесь, что ваш домен указывает на IP-адрес, где запущен ваш экземпляр NPM."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Не удалось проверить доступность из‑за ошибки связи с site24x7.com."
@ -652,5 +652,26 @@
},
"users": {
"defaultMessage": "Пользователи"
},
"wireguard-client": {
"defaultMessage": "WireGuard Клиент"
},
"wireguard-clients": {
"defaultMessage": "WireGuard Клиенты"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Клиент} other {WireGuard Клиентов}}"
},
"wireguard-server": {
"defaultMessage": "WireGuard Сервер"
},
"wireguard-server-links": {
"defaultMessage": "Связи WireGuard Серверов"
},
"wireguard-servers": {
"defaultMessage": "WireGuard Серверы"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Сервер} other {WireGuard Серверов}}"
}
}

View file

@ -204,7 +204,7 @@
"defaultMessage": "Táto sekcia vyžaduje znalosť Certbotu a jeho DNS doplnkov. Prosím, pozrite si dokumentáciu príslušného doplnku."
},
"certificates.http.reachability-404": {
"defaultMessage": "Na tejto doméne bol nájdený server, ale nezdá sa, že ide o Nginx Proxy Manager. Uistite sa, že vaša doména smeruje na IP, kde beží vaša inštancia NPM."
"defaultMessage": "Na tejto doméne bol nájdený server, ale nezdá sa, že ide o xGat3. Uistite sa, že vaša doména smeruje na IP, kde beží vaša inštancia NPM."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Nepodarilo sa overiť dostupnosť kvôli chybe komunikácie so službou site24x7.com."
@ -766,5 +766,26 @@
},
"users": {
"defaultMessage": "Používatelia"
},
"wireguard-client": {
"defaultMessage": "WireGuard Klient"
},
"wireguard-clients": {
"defaultMessage": "WireGuard Klienti"
},
"wireguard-clients.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Klient} other {WireGuard Klientov}}"
},
"wireguard-server": {
"defaultMessage": "WireGuard Server"
},
"wireguard-server-links": {
"defaultMessage": "WireGuard Prepojenia Serverov"
},
"wireguard-servers": {
"defaultMessage": "WireGuard Servery"
},
"wireguard-servers.count": {
"defaultMessage": "{count} {count, plural, one {WireGuard Server} other {WireGuard Serverov}}"
}
}

Some files were not shown because too many files have changed in this diff Show more