diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..84f72210 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "[markdown]": { + "editor.formatOnSave": false + }, + "[yaml]": { + "editor.formatOnSave": false + } +} diff --git a/README.md b/README.md index 026cc2f3..8e14abf1 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ You have found the easiest way to install & manage WireGuard on any Linux host! * Statistics for which clients are connected. * Tx/Rx charts for each connected client. * Gravatar support. +* Metrics in Prometheus format. ## Requirements @@ -87,6 +88,9 @@ These options can be configured by setting environment variables using `-e KEY=" | `WG_DEFAULT_ADDRESS` | `10.8.0.x` | `10.6.0.x` | Clients IP address range. | | `WG_DEFAULT_DNS` | `1.1.1.1` | `8.8.8.8, 8.8.4.4` | DNS server clients will use. | | `WG_ALLOWED_IPS` | `0.0.0.0/0, ::/0` | `192.168.15.0/24, 10.0.1.0/24` | Allowed IPs clients will use. | +| `METRICS_ENABLED` | `false` | `true` | When set, metrics in Prometheus format will be exposed. | +| `METRICS_USER` | - | `prometheus` | When set, HTTP Basic authorization with this user will be required when accessing metrics. | +| `METRICS_PASSWORD` | - | `password` | When set, HTTP Basic authorization will with this password be required when accessing metrics. | > If you change `WG_PORT`, make sure to also change the exposed port. @@ -100,4 +104,36 @@ docker rm wg-easy docker pull weejewel/wg-easy ``` -And then run the `docker run -d \ ...` command above again. \ No newline at end of file +And then run the `docker run -d \ ...` command above again. + +# Exposed metrics + +When metrics are enabled `wg-easy` will expose metrics in Prometheus format under `/metrics` path. HTTP Basic autorization is supported for metrics endpoint. + +Node process metrics specific to `wg-easy` are exported with `wg_easy_` prefix. WireGuard metrics are exported with `wireguard_` prefix. + +WireGuard metrics are inspired and compatible with metrics collected by [prometheus_wireguard_exporter](https://github.com/MindFlavor/prometheus_wireguard_exporter). Grafana dashboards created for [prometheus_wireguard_exporter](https://github.com/MindFlavor/prometheus_wireguard_exporter) works with metrics exposed by `wg-easy`. + +## Example WireGuard metrics + +``` +# HELP wireguard_sent_bytes_total Bytes sent to the peer +# TYPE wireguard_sent_bytes_total counter +wireguard_sent_bytes_total{interface="wg0",public_key="QpPNe62/SuCUSEkBTu3r2U0ihe2UrDspxUUgk195zmc=",allowed_ips="10.112.112.2/32",friendly_name="Test User 1",enabled="true"} 0 +wireguard_sent_bytes_total{interface="wg0",public_key="2AyHc7bRYJUJdx9UG87QmZDolj8xh6CORgP0PA28JT4=",allowed_ips="10.112.112.3/32",friendly_name="Test User 2",enabled="true"} 95788240 + +# HELP wireguard_received_bytes_total Bytes received from the peer +# TYPE wireguard_received_bytes_total counter +wireguard_received_bytes_total{interface="wg0",public_key="QpPNe62/SuCUSEkBTu3r2U0ihe2UrDspxUUgk195zmc=",allowed_ips="10.112.112.2/32",friendly_name="Test User 1",enabled="true"} 0 +wireguard_received_bytes_total{interface="wg0",public_key="2AyHc7bRYJUJdx9UG87QmZDolj8xh6CORgP0PA28JT4=",allowed_ips="10.112.112.3/32",friendly_name="Test User 2",enabled="true"} 54389700 + +# HELP wireguard_latest_handshake_seconds Seconds from the last handshake +# TYPE wireguard_latest_handshake_seconds gauge +wireguard_latest_handshake_seconds{interface="wg0",public_key="QpPNe62/SuCUSEkBTu3r2U0ihe2UrDspxUUgk195zmc=",allowed_ips="10.112.112.2/32",friendly_name="Test User 1",enabled="true"} 0 +wireguard_latest_handshake_seconds{interface="wg0",public_key="2AyHc7bRYJUJdx9UG87QmZDolj8xh6CORgP0PA28JT4=",allowed_ips="10.112.112.3/32",friendly_name="Test User 2",enabled="true"} 1633967910 + +# HELP wireguard_persistent_keepalive_seconds Seconds between each persistent keepalive packet +# TYPE wireguard_persistent_keepalive_seconds gauge +wireguard_persistent_keepalive_seconds{interface="wg0",public_key="QpPNe62/SuCUSEkBTu3r2U0ihe2UrDspxUUgk195zmc=",allowed_ips="10.112.112.2/32",friendly_name="Test User 1",enabled="true"} 0 +wireguard_persistent_keepalive_seconds{interface="wg0",public_key="2AyHc7bRYJUJdx9UG87QmZDolj8xh6CORgP0PA28JT4=",allowed_ips="10.112.112.3/32",friendly_name="Test User 2",enabled="true"} 0 +``` diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index de0347c1..f421af3a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,9 +1,12 @@ -version: "3.8" +version: '3.8' services: wg-easy: image: wg-easy command: npm run serve volumes: - ./src/:/app/ - # environment: + # environment: # - PASSWORD=p + # - METRICS_ENABLED=true + # - METRICS_USER=u + # - METRICS_PASSWORD=p diff --git a/docker-compose.yml b/docker-compose.yml index 601ccf95..fa38d5bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: "3.8" +version: '3.8' services: wg-easy: environment: @@ -12,14 +12,17 @@ services: # - WG_DEFAULT_ADDRESS=10.8.0.x # - WG_DEFAULT_DNS=1.1.1.1 # - WG_ALLOWED_IPS=192.168.15.0/24, 10.0.1.0/24 - + # - METRICS_ENABLED=true + # - METRICS_USER=prometheus + # - METRICS_PASSWORD=password + image: weejewel/wg-easy container_name: wg-easy volumes: - .:/etc/wireguard ports: - - "51820:51820/udp" - - "51821:51821/tcp" + - '51820:51820/udp' + - '51821:51821/tcp' restart: unless-stopped cap_add: - NET_ADMIN diff --git a/src/config.js b/src/config.js index d9cf5af4..123e2135 100644 --- a/src/config.js +++ b/src/config.js @@ -14,3 +14,6 @@ module.exports.WG_DEFAULT_DNS = typeof process.env.WG_DEFAULT_DNS === 'string' ? process.env.WG_DEFAULT_DNS : '1.1.1.1'; module.exports.WG_ALLOWED_IPS = process.env.WG_ALLOWED_IPS || '0.0.0.0/0, ::/0'; +module.exports.METRICS_ENABLED = process.env.METRICS_ENABLED === 'true' || false; +module.exports.METRICS_USER = process.env.METRICS_USER; +module.exports.METRICS_PASSWORD = process.env.METRICS_PASSWORD; diff --git a/src/lib/Metrics.js b/src/lib/Metrics.js new file mode 100644 index 00000000..73c6df07 --- /dev/null +++ b/src/lib/Metrics.js @@ -0,0 +1,71 @@ +'use strict'; + +const client = require('prom-client'); + +const { collectDefaultMetrics } = client; +collectDefaultMetrics({ prefix: 'wg_easy_' }); + +const sentBytesTotal = new client.Counter({ + name: 'wireguard_sent_bytes_total', + help: 'Bytes sent to the peer', + labelNames: ['interface', 'public_key', 'allowed_ips', 'friendly_name', 'enabled'], +}); + +const reveivedBytesTotal = new client.Counter({ + name: 'wireguard_received_bytes_total', + help: 'Bytes received from the peer', + labelNames: ['interface', 'public_key', 'allowed_ips', 'friendly_name', 'enabled'], +}); + +const latestHandshakeSeconds = new client.Gauge({ + name: 'wireguard_latest_handshake_seconds', + help: 'Seconds from the last handshake', + labelNames: ['interface', 'public_key', 'allowed_ips', 'friendly_name', 'enabled'], +}); + +const persistentKeepaliveSeconds = new client.Gauge({ + name: 'wireguard_persistent_keepalive_seconds', + help: 'Seconds between each persistent keepalive packet', + labelNames: ['interface', 'public_key', 'allowed_ips', 'friendly_name', 'enabled'], +}); + +module.exports = class Metrics { + + async getMetrics(wgClients) { + if (!wgClients) { + return client.register.metrics(); + } + + for (const wgClient of wgClients) { + const labels = { + interface: wgClient.interface, + public_key: wgClient.publicKey, + allowed_ips: wgClient.allowedIPs, + friendly_name: wgClient.name, + enabled: wgClient.enabled, + }; + + sentBytesTotal.remove(labels); + sentBytesTotal.labels(labels).inc(wgClient.transferTx || 0); + + reveivedBytesTotal.remove(labels); + reveivedBytesTotal.labels(labels).inc(wgClient.transferRx || 0); + + if (!wgClient.latestHandshakeAt) { + latestHandshakeSeconds.labels(labels).set(0); + } else { + const seconds = Math.round(Date.parse(wgClient.latestHandshakeAt) / 1000.0); + latestHandshakeSeconds.labels(labels).set(seconds); + } + + if (!wgClient.persistentKeepalive || wgClient.persistentKeepalive === 'off') { + persistentKeepaliveSeconds.labels(labels).set(0); + } else { + persistentKeepaliveSeconds.labels(labels).set(wgClient.persistentKeepalive); + } + } + + return client.register.metrics(); + } + +}; diff --git a/src/lib/Server.js b/src/lib/Server.js index e204fa5f..a4684315 100644 --- a/src/lib/Server.js +++ b/src/lib/Server.js @@ -9,11 +9,15 @@ const debug = require('debug')('Server'); const Util = require('./Util'); const ServerError = require('./ServerError'); const WireGuard = require('../services/WireGuard'); +const Metrics = require('../services/Metrics'); const { PORT, RELEASE, PASSWORD, + METRICS_ENABLED, + METRICS_USER, + METRICS_PASSWORD, } = require('../config'); module.exports = class Server { @@ -23,6 +27,36 @@ module.exports = class Server { this.app = express() .disable('etag') .use('/', express.static(path.join(__dirname, '..', 'www'))) + + // Metrics + .get('/metrics', (Util.promisify(async (req, res) => { + if (!METRICS_ENABLED) { + throw new ServerError('Metrics Disabled', 400); + } + + res.set('WWW-Authenticate', 'Basic realm="WireGuard Metrics"'); + + if (METRICS_USER || METRICS_PASSWORD) { + if (!req.headers['authorization']) { + throw new ServerError('Unauthorized', 401); + } + + const [authScheme, authCredentials] = req.headers['authorization'].split(' '); + if (authScheme !== 'Basic' || !authCredentials) { + throw new ServerError('Unauthorized', 401); + } + + const credentials = Buffer.from(`${METRICS_USER || ''}:${METRICS_PASSWORD || ''}`).toString('base64'); + if (authCredentials !== credentials) { + throw new ServerError('Unauthorized', 401); + } + } + + const metrics = await Metrics.getMetrics(await WireGuard.getClients()); + res.header('Content-Type', 'text/plain'); + res.send(metrics); + }))) + .use(express.json()) .use(expressSession({ secret: String(Math.random()), diff --git a/src/lib/WireGuard.js b/src/lib/WireGuard.js index ea88e6a3..079ae3fc 100644 --- a/src/lib/WireGuard.js +++ b/src/lib/WireGuard.js @@ -114,6 +114,7 @@ AllowedIPs = ${client.address}/32`; const config = await this.getConfig(); const clients = Object.entries(config.clients).map(([clientId, client]) => ({ id: clientId, + interface: 'wg0', name: client.name, enabled: client.enabled, address: client.address, diff --git a/src/package-lock.json b/src/package-lock.json index 80dd23d3..1f1f72e2 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -342,6 +342,16 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" + }, + "bintrees": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz", + "integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=" + }, "body-parser": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", @@ -1955,6 +1965,14 @@ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true }, + "prom-client": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-14.0.0.tgz", + "integrity": "sha512-etPa4SMO4j6qTn2uaSZy7+uahGK0kXUZwO7WhoDpTf3yZ837I3jqUDYmG6N0caxuU6cyqrg0xmOxh+yneczvyA==", + "requires": { + "tdigest": "^0.1.1" + } + }, "proxy-addr": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz", @@ -2362,6 +2380,19 @@ } } }, + "tdigest": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz", + "integrity": "sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=", + "requires": { + "bintrees": "1.0.1" + } + }, + "term-size": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", + "integrity": "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==" + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/src/package.json b/src/package.json index fcef39a6..f5a4c596 100644 --- a/src/package.json +++ b/src/package.json @@ -15,6 +15,8 @@ "debug": "^4.3.1", "express": "^4.17.1", "express-session": "^1.17.1", + "nodemon": "^2.0.12", + "prom-client": "^14.0.0", "qrcode": "^1.4.4", "uuid": "^8.3.2" }, @@ -30,4 +32,4 @@ "engines": { "node": "14" } -} \ No newline at end of file +} diff --git a/src/services/Metrics.js b/src/services/Metrics.js new file mode 100644 index 00000000..d3a9e2aa --- /dev/null +++ b/src/services/Metrics.js @@ -0,0 +1,5 @@ +'use strict'; + +const Metrics = require('../lib/Metrics'); + +module.exports = new Metrics();