master
Keith Irwin 2021-11-14 02:34:39 +00:00
commit cf40f571aa
9 changed files with 169 additions and 62 deletions

103
README.md
View File

@ -4,53 +4,118 @@
## Installation ## Installation
### Docker ### with Docker
Install docker and docker-compose. Then create a project directory and pull the `docker-compose.yml` file Install docker and docker-compose. Then create a project directory and pull the sample environment files.
``` ```sh
$ sudo mkdir /srv/wgapi sudo mkdir /srv/wgapi
$ cd /srv/wgapi cd /srv/wgapi
$ sudo wget https://gitea.gf4.pw/gf4/wgapi/raw/branch/master/docker-compose.yml sudo wget 'https://gitea.gf4.pw/gf4/wgapi/raw/branch/master/docker-compose.yml.sample'
sudo wget 'https://gitea.gf4.pw/gf4/wgapi/raw/branch/master/env.json.sample'
sudo cp docker-compose.yml.sample docker-compose.yml
sudo cp env.json.sample env.json
``` ```
Edit the compose file according to its comments. Then you can start the service. Edit `docker-compose.yml` and `env.json` files according to their comments. Then you can start the service:
``` ```sh
docker-compose up -d docker-compose up -d
``` ```
The API will modify your wireguard configuration file. Changes will not take effect unless the interface is restarted periodically. One way of doing this is with a systemd timer. The API will modify your wireguard configuration file. Changes will not take effect unless the interface is restarted periodically. One way of doing this is with a systemd timer.
TODO: Add systemd timer and instructions ### systemd
**NOTE:** The API is not protected by any authentication. As it stands, anyone can connect to your API and access your wireguard network! Be sure to protect it with authentication in a web proxy or by blocking access with a firewall. The API edits the wireguard config in the background but doesn't restart the service. To have changes take effect every 10 minutes, you can use this repo's systemd unit files:
```sh
cd /etc/systemd/system
sudo wget 'https://gitea.gf4.pw/gf4/wgapi/raw/branch/master/systemd/restart-wg-quick@.service'
sudo wget 'https://gitea.gf4.pw/gf4/wgapi/raw/branch/master/systemd/restart-wg-quick@.timer'
sudo systemctl daemon-reload
# Replace this with your interface
sudo systemctl start restart-wg-quick@wg0.timer
```
If that works, make it run on boot:
```sh
sudo systemctl enable restart-wg-quick@wg0.timer
```
## Usage ## Usage
Once the server is listening, there are two endpoints that clients can direct requests to. Once the server is listening, there are three endpoints that clients can direct requests to.
### List (/list)
This endpoint returns a user's `user` object, including an auth token, containing all the peer information in `user.peers`.
#### Request
Just `GET /list` and this endpoint will detect who you are based on your IP and return your user object.
#### Response
Returns the user object and peers as json.
```json
{
"name": myusername,
"token": longsecrettokenhere,
"subnet": "8",
"peers": [
{
"name": "host1",
"ipv4": "10.5.8.1"
"ipv6": "fd69:1337:0:420:f4:f5:8:1"
},
{
"name": "host2",
"ipv4": "10.5.8.2"
"ipv6": "fd69:1337:0:420:f4:f5:8:2"
}
]
}
```
...
### Add ### Add
This endpoint adds a peer to the wireguard server. This endpoint adds a peer to the wireguard servers and adds its IP address to the nameserver. To guard against IP spoofing, it requires a token from a `/list` request.
TODO: Write how to use it. #### Request
Simply `GET /add?token=MYTOKEN&name=host3` where `MYTOKEN` is the secret token from the `/list` request and `host3` is the new hostname. The backend will add your new peer to its wireguard config and inform other servers of the new peer. Then it will modify the nameserver to add your peer's IP addresses under the domain `host3.myusername.tld`.
#### Response
A successful `/add` request will return the new peer's wireguard configuration as plaintext. Copy and paste it to your client machine's `/etc/wireguard/wg0.conf` file.
A failed requst will return an error code. `5XX` HTTP codes provide have errors in the log.
### Delete ### Delete
This endpoint deletes a peer from the server. This endpoint deletes a peer from the wireguard servers and removes its domain from the nameserver. To guard against IP spoofing, it requires a token from a `/list` request.
TODO: Write how to use it. #### Request
## Notes After getting a token from a `/list` request, a peer can be identified and deleted using any of these requests:
After a config has been downloaded by a client, the user is free to modify it to peer with friends' hosts. - `GET /del?token=MYTOKEN&name=host3` using the hostname
- `GET /del?token=MYTOKEN&pubkey=PUBKEY` using a wireguard public key
- `GET /del?token=MYTOKEN&privkey=PRIVKEY` using a wireguard private key
- `GET /del?token=MYTOKEN&psk=PSK` using the wireguard preshared key
- `GET /del?token=MYTOKEN&ip=IP` using an IPv4 or IPv6 address
TODO: Add instructions on how to do this. #### Response
It will simply return `200 OK` in case of success. `5XX` HTTP codes provide have errors in the log.
## License (GPLv2) ## License (GPLv2)
Copyright © 2021 Keith Irwin Copyright © 2021 Keith Irwin
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version.

View File

@ -19,12 +19,18 @@ let axios; (async()=>{
} catch (err) { console.error(err) } } catch (err) { console.error(err) }
})() })()
const dns_key = `hmac-sha512:wgapi-${env.LOCAL_SERVER}:${env.DNS_KEY}` const dns_key = `hmac-sha512:wgapi-${env.LOCAL_SERVER}:${env.DNS_KEY}`
let config_queue = []
module.exports = async (req, res) => { module.exports = async (req, res) => {
const new_hostname = req.query['name']
// Parse, sanitize, and validate hostname
const new_hostname = req.query['name'].trim().toLowerCase()
if (!new_hostname) { if (!new_hostname) {
console.log(`New peer request from ${req.requester} didn't provide a hostname`) console.log(`New peer request from ${req.requester} didn't provide a hostname`)
return res.sendStatus(400) return res.sendStatus(400)
} else if (!/^([\-\_a-z0-9]{1,20})$/.test(new_hostname)) {
console.log(`New peer request from ${req.requester} provided an invalid hostname: ${new_hostname}`)
return res.sendStatus(400)
} else console.log(`New peer request from ${req.requester} for ${new_hostname}`) } else console.log(`New peer request from ${req.requester} for ${new_hostname}`)
// Get user from IP // Get user from IP
@ -42,22 +48,29 @@ module.exports = async (req, res) => {
// Check if new peer already exists // Check if new peer already exists
if (user.peers.map((peer) => peer.name).includes(new_hostname)) { if (user.peers.map((peer) => peer.name).includes(new_hostname)) {
console.log(`Host already exists for ${new_hostname}.${user.name}.${env.TLD}`) console.log(`Host already exists for ${domain}`)
return res.sendStatus(409) return res.sendStatus(409)
} }
const domain = `${new_hostname}.${user.name}.${env.TLD}`
// Find next available host part // Find next available host part
const used_ipv4_hosts = user.peers const used_ipv4_hosts = user.peers
.map((host) => host.ipv4).map((found_ipv4) => .map((host) => host.ipv4).map((found_ipv4) =>
found_ipv4.toString().split('.')[3].split('/')[0]) found_ipv4.toString().split('.')[3])
const used_ipv6_hosts = user.peers const used_ipv6_hosts = user.peers
.map((host) => host.ipv6).map((found_ipv6) => .map((host) => host.ipv6).map((found_ipv6) =>
found_ipv6.toString().split(':')[3].split('/')[0]) found_ipv6.toString().split(':')[3])
let host = 1 let host = 1
while ([...used_ipv4_hosts,...used_ipv6_hosts].includes(host.toString())) host++ while ([...used_ipv4_hosts,...used_ipv6_hosts].includes(host.toString())) host++
if (host>9999) {
console.error(`New host part for ${user.name} is higher than 9999: ${host}`)
return res.sendStatus(507)
}
// Create IP Addresses and keys // Create IP Addresses and keys
const ipv4_addr = `${env.IPV4_NET}.${user.subnet}.${host}` let ipv4_addr; if (host<254)
ipv4_addr = `${env.IPV4_NET}.${user.subnet}.${host}`
const ipv6_addr = `${env.IPV6_NET}:${user.subnet}:${host}` const ipv6_addr = `${env.IPV6_NET}:${user.subnet}:${host}`
let keypair; try { let keypair; try {
keypair = await wg.generateKeypair() keypair = await wg.generateKeypair()
@ -88,11 +101,15 @@ Endpoint = ${server.endpoint}
PersistentKeepAlive = 25`) PersistentKeepAlive = 25`)
// Add new user device to server config as [Peer] // Add new user device to server config as [Peer]
const server_config = `\n const allowed_ips = ipv4_addr
[Peer] # ${new_hostname}.${user.name}.${env.TLD} ? `${ipv4_addr}/32, ${ipv6_addr}/128`
: `${ipv6_addr}/128`
const server_config = `
[Peer] # ${domain}
PublicKey = ${keypair[0]} PublicKey = ${keypair[0]}
PresharedKey = ${psk} PresharedKey = ${psk}
AllowedIPs = ${ipv4_addr}/32, ${ipv6_addr}/128` AllowedIPs = ${allowed_ips}
`
// Add server_config to local wg0.conf // Add server_config to local wg0.conf
if (server.host===env.LOCAL_SERVER) { if (server.host===env.LOCAL_SERVER) {
@ -106,36 +123,40 @@ AllowedIPs = ${ipv4_addr}/32, ${ipv6_addr}/128`
await axios.post(`${server.admin_endpoint}/add?secret=${server.secret}`, server_config, { await axios.post(`${server.admin_endpoint}/add?secret=${server.secret}`, server_config, {
headers: {'Content-Type': 'text/plain'}, headers: {'Content-Type': 'text/plain'},
}) })
} catch (err) { } catch (err) {
if (err.message==='Request failed with status code 403') { if (err.message==='Request failed with status code 403') {
console.error(`Received 403 from ${server.admin_endpoint}/add`) console.error(`Received 403 from ${server.admin_endpoint}/add`)
} else { } else {
console.error(err) console.error(`Failed to add peer to ${server.host}:\n${server_config}`)
//TODO: Handle other servers that are down (hold the config and retry?) if (err) console.error(err.message)
} }
} }
} }
} }
// Update nameserver // Update nameserver
const domain = `${new_hostname}.${user.name}.${env.TLD}.` if (env.ENV==='prod') {
try { try {
await helper.nsUpdate(dns_key, env.DNS_MASTER, await helper.nsUpdate(dns_key, env.DNS_MASTER,
`update add ${domain} ${env.DNS_TTL} A ${ipv4_addr} `update add ${domain}. ${env.DNS_TTL} A ${ipv4_addr}
update add ${domain} ${env.DNS_TTL} AAAA ${ipv6_addr} update add ${domain}. ${env.DNS_TTL} AAAA ${ipv6_addr}
update add *.${domain} ${env.DNS_TTL} CNAME ${domain}`) update add *.${domain}. ${env.DNS_TTL} CNAME ${domain}.`)
}
catch (err) {
console.error(`Failed to add ns record.`)
if (err) console.error(err)
}
console.log(`Updated nameserver to add ${domain}.`)
} }
catch (err) {
console.error(`Failed to add ns record.`)
if (err) console.error(err)
}
console.log(`Updated nameserver to add ${domain}.`)
// Generate user config // Generate user config
const listen_port = Math.floor(50000 + Math.random() * 10000) const listen_port = Math.floor(50000 + Math.random() * 10000)
const config = `[Interface] const config_address = ipv4_addr
? `${ipv4_addr}/${env.IPV4_CIDR}, ${ipv6_addr}/${env.IPV6_CIDR}`
: `${ipv6_addr}/${env.IPV6_CIDR}`
const config = `[Interface] # ${domain}
PrivateKey = ${keypair[1]} PrivateKey = ${keypair[1]}
Address = ${ipv4_addr}/${env.IPV4_CIDR}, ${ipv6_addr}/${env.IPV6_CIDR} Address = ${config_address}
DNS = ${res.locals.DNS_SERVERS_STRING} DNS = ${res.locals.DNS_SERVERS_STRING}
ListenPort = ${listen_port} ListenPort = ${listen_port}
PostUp = resolvectl domain ${env.TLD} ${env.TLD} PostUp = resolvectl domain ${env.TLD} ${env.TLD}

View File

@ -21,7 +21,12 @@ let axios; (async()=>{
const dns_key = `hmac-sha512:wgapi-${env.LOCAL_SERVER}:${env.DNS_KEY}` const dns_key = `hmac-sha512:wgapi-${env.LOCAL_SERVER}:${env.DNS_KEY}`
module.exports = async (req, res) => { module.exports = async (req, res) => {
console.log(`Received request from ${req.requester} to delete ${JSON.stringify(req.query)}`) const target = req.query['name']
|| req.query['psk']
|| req.query['ip']
|| req.query['pubkey']
|| req.query['privkey']
console.log(`Received request from ${req.requester} to delete ${target}`)
// Get user from IP // Get user from IP
let user; try { user = await helper.getUserFromIp(req.requester) } let user; try { user = await helper.getUserFromIp(req.requester) }
@ -89,7 +94,7 @@ module.exports = async (req, res) => {
.split(' # ')[1] .split(' # ')[1]
const peer_ips = peer_lines const peer_ips = peer_lines
.filter( (line) => line.includes('AllowedIPs = '))[0] .filter( (line) => line.includes('AllowedIPs = '))[0]
.split(' = ')[1] .split(' = ')[1].split(', ')
if (peer_pubkey===undefined) { if (peer_pubkey===undefined) {
peer_pubkey = peer_lines peer_pubkey = peer_lines
.filter( (line) => line.includes('PublicKey = ') )[0] .filter( (line) => line.includes('PublicKey = ') )[0]
@ -122,25 +127,28 @@ module.exports = async (req, res) => {
headers: {'Content-Type': 'text/plain'}, headers: {'Content-Type': 'text/plain'},
}) })
} catch (err) { } catch (err) {
console.error(`Failed to inform ${server.host} to delete ${peer_name}:\n\n`,err) if (err) console.error(err)
console.error(`Failed to inform ${server.host} to delete ${peer_name}!`)
return res.sendStatus(500) return res.sendStatus(500)
} }
} }
} }
// Delete domains from nameserver // Delete domains from nameserver
try { if (env.ENV==='prod') {
await helper.nsUpdate(dns_key, env.DNS_MASTER, try {
await helper.nsUpdate(dns_key, env.DNS_MASTER,
`update delete ${peer_name}. A `update delete ${peer_name}. A
update delete ${peer_name}. AAAA update delete ${peer_name}. AAAA
update delete *.${peer_name}. CNAME`) update delete *.${peer_name}. CNAME`)
}
catch (err) {
console.error(`Failed to delete ns record`)
if (err) console.error(err)
return res.sendStatus(500)
}
console.log(`Updated nameserver to delete ${peer_name}.`)
} }
catch (err) {
console.error(`Failed to delete ns record`)
if (err) console.error(err)
return res.sendStatus(500)
}
console.log(`Updated nameserver to delete ${peer_name}.`)
return res.sendStatus(200) return res.sendStatus(200)

View File

@ -38,8 +38,8 @@ module.exports = {
} }
else if (line.includes('AllowedIPs = ')) { else if (line.includes('AllowedIPs = ')) {
const ips = line.split('=')[1].split(', ') const ips = line.split('=')[1].split(', ')
userpeer_obj.ipv4 = ips.filter( (ip) => ip.includes(env.IPV4_NET) )[0].trim() userpeer_obj.ipv4 = ips.filter( (ip) => ip.includes(env.IPV4_NET) )[0].trim().split('/')[0]
userpeer_obj.ipv6 = ips.filter( (ip) => ip.includes(env.IPV6_NET) )[0].trim() userpeer_obj.ipv6 = ips.filter( (ip) => ip.includes(env.IPV6_NET) )[0].trim().split('/')[0]
} }
} }
found_hosts.push(userpeer_obj) found_hosts.push(userpeer_obj)

View File

@ -6,9 +6,22 @@
const env = require(process.argv[2]||'./env.json') const env = require(process.argv[2]||'./env.json')
const mw = require('./includes/middleware.js') const mw = require('./includes/middleware.js')
const express = require('express') const express = require('express')
const fs = require('fs').promises
const app = express() const app = express()
const admin = express() const admin = express()
// Check the wireguard config file for '::'
;(async (f) => {
let config; try {
config = (await fs.readFile(f)).toString()
} catch (err) {
console.error(`Failed to read ${f}!`)
if (err) console.error(err.message)
}
if (config.includes('::'))
console.error(`Found double colons (::) in ${f}! Please expand all IPv6 addresses to prevent parsing issues!`)
})(env.WG_CONFIG_FILE)
app.set('trust proxy', true) app.set('trust proxy', true)
.use(mw.getRequester) .use(mw.getRequester)
.get('/', (req, res) => res.redirect('/list')) .get('/', (req, res) => res.redirect('/list'))

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "wgapi", "name": "wgapi",
"version": "1.0.1", "version": "1.1.1",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wgapi", "name": "wgapi",
"version": "1.0.1", "version": "1.1.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^0.23.0", "axios": "^0.23.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "wgapi", "name": "wgapi",
"version": "1.0.4", "version": "1.1.1",
"description": "HTTP API to add and remove wireguard configs", "description": "HTTP API to add and remove wireguard configs",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -1,6 +1,6 @@
[Unit] [Unit]
Description=Restart a wireguard service Description=Restart a wireguard service
Requires=wg-quick@%i After=wg-quick@%i.service
[Service] [Service]
Type=oneshot Type=oneshot

View File

@ -1,9 +1,9 @@
[Unit] [Unit]
Description=Restart wg-quick@%i every 10 minutes Description=Restart wg-quick@%i every 10 minutes
Requires=wg-quick@%i.service After=wg-quick@%i.service
[Timer] [Timer]
OnCalendar=OnCalendar=*:0/10 OnCalendar=*:0/10
Unit=restart-wg-quick@%i.service Unit=restart-wg-quick@%i.service
[Install] [Install]