Simple POC for creating easy way to manage wireguard peer configs on the server.
This commit is contained in:
commit
937e3f272a
87
README.md
Normal file
87
README.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# Wireguard Peer Manager
|
||||||
|
|
||||||
|
This is simple CURD for managing wireguard peer notations on a wireguard server config.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.x
|
||||||
|
- `requests` library (for the client)
|
||||||
|
- WireGuard (`wg-quick` and `wg` commands must be available on the server)
|
||||||
|
|
||||||
|
## Server: wpm.py
|
||||||
|
|
||||||
|
### How to Run the Server
|
||||||
|
|
||||||
|
`python wpm.py`
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
GET /peers: List all peers.
|
||||||
|
POST /peers: Add a new peer.
|
||||||
|
PUT /peers/<PublicKey>: Update an existing peer.
|
||||||
|
DELETE /peers/<PublicKey>: Delete an existing peer.
|
||||||
|
POST /restore: Restore the WireGuard configuration from a backup.
|
||||||
|
|
||||||
|
|
||||||
|
## Client: wpm_client.py
|
||||||
|
|
||||||
|
The client script allows interaction with the WireGuard Peer Management API.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
|
||||||
|
python wpm_client.py <action> [options]
|
||||||
|
|
||||||
|
### Available Actions
|
||||||
|
|
||||||
|
create: Create a new peer.
|
||||||
|
Required options: --public-key, --allowed-ips
|
||||||
|
update: Update an existing peer.
|
||||||
|
Required options: --public-key, --allowed-ips
|
||||||
|
delete: Delete a peer by its public key.
|
||||||
|
Required options: --public-key
|
||||||
|
list: List all peers.
|
||||||
|
restore: Restore the WireGuard configuration from the most recent backup.
|
||||||
|
|
||||||
|
### Example Usage
|
||||||
|
|
||||||
|
List Peers:
|
||||||
|
|
||||||
|
```
|
||||||
|
python wpm_client.py list
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a New Peer:
|
||||||
|
```
|
||||||
|
python wpm_client.py create --public-key "<peer-public-key>" --allowed-ips "10.0.0.2/32"
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Update an Existing Peer:
|
||||||
|
```
|
||||||
|
python wpm_client.py update --public-key "<peer-public-key>" --allowed-ips "10.0.0.3/32"
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete a Peer:
|
||||||
|
```
|
||||||
|
python wpm_client.py delete --public-key "<peer-public-key>"
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore Configuration:
|
||||||
|
|
||||||
|
```
|
||||||
|
python wpm_client.py restore
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup and Restore
|
||||||
|
|
||||||
|
The server automatically creates a backup before making any changes to the WireGuard configuration. The backups are stored in the same directory as the configuration file, inside a backups/ folder.
|
||||||
|
|
||||||
|
You can restore the latest backup by sending a POST /restore request, which can be done using the client or via curl:
|
||||||
|
|
||||||
|
curl -X POST http://localhost:8000/restore
|
||||||
|
|
11
config.toml
Normal file
11
config.toml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
[server]
|
||||||
|
host = "127.0.0.1" # IP address to bind to
|
||||||
|
port = 8080 # Port to bind to
|
||||||
|
|
||||||
|
[wireguard]
|
||||||
|
config_file = "../wireguard_example_server_config.conf"
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
log_file = "../wpm.log" # Optional: Log file location
|
||||||
|
debug = true # Enable debug logging
|
||||||
|
|
257
wpm.py
Normal file
257
wpm.py
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
import http.server
|
||||||
|
import socketserver
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.parse
|
||||||
|
import tomllib
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Default configuration
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
"server": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8000,
|
||||||
|
},
|
||||||
|
"wireguard": {
|
||||||
|
"config_file": "/etc/wireguard/wg0.conf",
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"log_file": None,
|
||||||
|
"debug": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_config(config_file: str) -> dict:
|
||||||
|
try:
|
||||||
|
with open(config_file, "rb") as f:
|
||||||
|
config = tomllib.load(f)
|
||||||
|
# Merge with default config
|
||||||
|
return {**DEFAULT_CONFIG, **config}
|
||||||
|
except FileNotFoundError:
|
||||||
|
logging.warning(f"Config file {config_file} not found. Using default configuration.")
|
||||||
|
return DEFAULT_CONFIG
|
||||||
|
except tomllib.TOMLDecodeError as e:
|
||||||
|
logging.error(f"Error parsing config file: {e}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
CONFIG = load_config("config.toml")
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if CONFIG["logging"]["debug"] else logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
filename=CONFIG["logging"]["log_file"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def read_config() -> str:
|
||||||
|
with open(CONFIG["wireguard"]["config_file"], 'r') as file:
|
||||||
|
return file.read()
|
||||||
|
|
||||||
|
def write_config(content: str) -> None:
|
||||||
|
with open(CONFIG["wireguard"]["config_file"], 'w') as file:
|
||||||
|
file.write(content)
|
||||||
|
|
||||||
|
def create_backup():
|
||||||
|
config_file = Path(CONFIG["wireguard"]["config_file"])
|
||||||
|
backup_dir = config_file.parent / "backups"
|
||||||
|
backup_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
backup_file = backup_dir / f"{config_file.stem}_{timestamp}.conf"
|
||||||
|
|
||||||
|
shutil.copy2(config_file, backup_file)
|
||||||
|
logging.info(f"Backup created: {backup_file}")
|
||||||
|
return backup_file
|
||||||
|
|
||||||
|
def restore_from_backup():
|
||||||
|
config_file = Path(CONFIG["wireguard"]["config_file"])
|
||||||
|
backup_dir = config_file.parent / "backups"
|
||||||
|
|
||||||
|
if not backup_dir.exists() or not list(backup_dir.glob("*.conf")):
|
||||||
|
logging.error("No backups found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
latest_backup = max(backup_dir.glob("*.conf"), key=lambda f: f.stat().st_mtime)
|
||||||
|
shutil.copy2(latest_backup, config_file)
|
||||||
|
logging.info(f"Restored from backup: {latest_backup}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def parse_peers(config: str) -> List[Dict[str, str]]:
|
||||||
|
peers = []
|
||||||
|
current_peer = None
|
||||||
|
for line in config.split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if line == "[Peer]":
|
||||||
|
if current_peer:
|
||||||
|
peers.append(current_peer)
|
||||||
|
current_peer = {}
|
||||||
|
elif current_peer is not None and '=' in line:
|
||||||
|
key, value = line.split('=', 1)
|
||||||
|
current_peer[key.strip()] = value.strip()
|
||||||
|
if current_peer:
|
||||||
|
peers.append(current_peer)
|
||||||
|
return peers
|
||||||
|
|
||||||
|
def peer_to_string(peer: Dict[str, str]) -> str:
|
||||||
|
return f"[Peer]\n" + "\n".join(f"{k} = {v}" for k, v in peer.items())
|
||||||
|
|
||||||
|
def reload_wireguard_service():
|
||||||
|
interface = Path(CONFIG["wireguard"]["config_file"]).stem
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if wg-quick is available
|
||||||
|
subprocess.run(["which", "wg-quick"], check=True, capture_output=True)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
logging.warning("wg-quick not found. WireGuard might not be installed.")
|
||||||
|
return False, "WireGuard (wg-quick) not found. Please ensure WireGuard is installed."
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if the interface is up
|
||||||
|
result = subprocess.run(["wg", "show", interface], capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Interface is up, we'll restart it
|
||||||
|
subprocess.run(["wg-quick", "down", interface], check=True)
|
||||||
|
subprocess.run(["wg-quick", "up", interface], check=True)
|
||||||
|
logging.info(f"WireGuard service for interface {interface} restarted successfully")
|
||||||
|
else:
|
||||||
|
# Interface is down, we'll just bring it up
|
||||||
|
subprocess.run(["wg-quick", "up", interface], check=True)
|
||||||
|
logging.info(f"WireGuard service for interface {interface} started successfully")
|
||||||
|
|
||||||
|
return True, f"WireGuard service for interface {interface} reloaded successfully"
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
error_message = f"Failed to reload WireGuard service: {e}"
|
||||||
|
if e.stderr:
|
||||||
|
error_message += f"\nError details: {e.stderr.decode('utf-8')}"
|
||||||
|
logging.error(error_message)
|
||||||
|
return False, error_message
|
||||||
|
|
||||||
|
class WireGuardHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
def _send_response(self, status_code: int, data: dict) -> None:
|
||||||
|
self.send_response(status_code)
|
||||||
|
self.send_header('Content-type', 'application/json')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(data).encode())
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
if self.path == '/peers':
|
||||||
|
config = read_config()
|
||||||
|
peers = parse_peers(config)
|
||||||
|
self._send_response(200, peers)
|
||||||
|
else:
|
||||||
|
self._send_response(404, {"error": "Not found"})
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
if self.path == '/peers':
|
||||||
|
create_backup() # Create a backup before making changes
|
||||||
|
content_length = int(self.headers['Content-Length'])
|
||||||
|
post_data = self.rfile.read(content_length)
|
||||||
|
new_peer = json.loads(post_data.decode('utf-8'))
|
||||||
|
|
||||||
|
config = read_config()
|
||||||
|
config += "\n\n" + peer_to_string(new_peer)
|
||||||
|
write_config(config)
|
||||||
|
|
||||||
|
if reload_wireguard_service():
|
||||||
|
self._send_response(201, {"message": "Peer added successfully and service reloaded"})
|
||||||
|
else:
|
||||||
|
self._send_response(500, {"error": "Peer added but failed to reload service"})
|
||||||
|
elif self.path == '/restore':
|
||||||
|
if restore_from_backup():
|
||||||
|
if reload_wireguard_service():
|
||||||
|
self._send_response(200, {"message": "Configuration restored from backup and service reloaded"})
|
||||||
|
else:
|
||||||
|
self._send_response(500, {"error": "Configuration restored but failed to reload service"})
|
||||||
|
else:
|
||||||
|
self._send_response(500, {"error": "Failed to restore from backup"})
|
||||||
|
else:
|
||||||
|
self._send_response(404, {"error": "Not found"})
|
||||||
|
|
||||||
|
def do_PUT(self) -> None:
|
||||||
|
path_parts = self.path.split('/')
|
||||||
|
if len(path_parts) == 3 and path_parts[1] == 'peers':
|
||||||
|
create_backup() # Create a backup before making changes
|
||||||
|
public_key = urllib.parse.unquote(path_parts[2])
|
||||||
|
content_length = int(self.headers['Content-Length'])
|
||||||
|
put_data = self.rfile.read(content_length)
|
||||||
|
updated_peer = json.loads(put_data.decode('utf-8'))
|
||||||
|
|
||||||
|
config = read_config()
|
||||||
|
peers = parse_peers(config)
|
||||||
|
|
||||||
|
peer_found = False
|
||||||
|
for i, peer in enumerate(peers):
|
||||||
|
if peer.get('PublicKey') == public_key:
|
||||||
|
peer_found = True
|
||||||
|
# Update the peer
|
||||||
|
peers[i] = updated_peer
|
||||||
|
new_config = re.sub(r'(\[Interface\].*?\n\n)(.*)',
|
||||||
|
r'\1' + '\n\n'.join(peer_to_string(p) for p in peers),
|
||||||
|
config,
|
||||||
|
flags=re.DOTALL)
|
||||||
|
write_config(new_config)
|
||||||
|
|
||||||
|
# Reload WireGuard service and send the appropriate response
|
||||||
|
success, message = reload_wireguard_service()
|
||||||
|
if success:
|
||||||
|
self._send_response(200, {"message": "Peer updated successfully and service reloaded"})
|
||||||
|
else:
|
||||||
|
self._send_response(500, {"error": f"Peer updated but failed to reload service: {message}"})
|
||||||
|
break
|
||||||
|
|
||||||
|
if not peer_found:
|
||||||
|
# If no peer with the given public key was found, return a 404 response
|
||||||
|
self._send_response(404, {"error": "Peer not found"})
|
||||||
|
else:
|
||||||
|
self._send_response(404, {"error": "Not found"})
|
||||||
|
|
||||||
|
def do_DELETE(self) -> None:
|
||||||
|
path_parts = self.path.split('/')
|
||||||
|
if len(path_parts) == 3 and path_parts[1] == 'peers':
|
||||||
|
create_backup() # Create a backup before making changes
|
||||||
|
public_key = urllib.parse.unquote(path_parts[2])
|
||||||
|
|
||||||
|
config = read_config()
|
||||||
|
peers = parse_peers(config)
|
||||||
|
|
||||||
|
peer_found = False
|
||||||
|
for peer in peers:
|
||||||
|
if peer.get('PublicKey') == public_key:
|
||||||
|
peer_found = True
|
||||||
|
# Remove the peer
|
||||||
|
peers.remove(peer)
|
||||||
|
new_config = re.sub(r'(\[Interface\].*?\n\n)(.*)',
|
||||||
|
r'\1' + '\n\n'.join(peer_to_string(p) for p in peers),
|
||||||
|
config,
|
||||||
|
flags=re.DOTALL)
|
||||||
|
write_config(new_config)
|
||||||
|
|
||||||
|
# Reload WireGuard service and send the appropriate response
|
||||||
|
success, message = reload_wireguard_service()
|
||||||
|
if success:
|
||||||
|
self._send_response(200, {"message": "Peer deleted successfully and service reloaded"})
|
||||||
|
else:
|
||||||
|
self._send_response(500, {"error": f"Peer deleted but failed to reload service: {message}"})
|
||||||
|
break
|
||||||
|
|
||||||
|
if not peer_found:
|
||||||
|
# If no peer with the given public key was found, return a 404 response
|
||||||
|
self._send_response(404, {"error": "Peer not found"})
|
||||||
|
else:
|
||||||
|
self._send_response(404, {"error": "Not found"})
|
||||||
|
|
||||||
|
def run_server() -> None:
|
||||||
|
host = CONFIG["server"]["host"]
|
||||||
|
port = CONFIG["server"]["port"]
|
||||||
|
with socketserver.TCPServer((host, port), WireGuardHandler) as httpd:
|
||||||
|
logging.info(f"Serving on {host}:{port}")
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_server()
|
||||||
|
|
84
wpm_client.py
Normal file
84
wpm_client.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import argparse
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
BASE_URL = "http://localhost:8080" # Update this if your server is running on a different host or port
|
||||||
|
|
||||||
|
def create_or_update_peer(public_key, allowed_ips):
|
||||||
|
url = f"{BASE_URL}/peers"
|
||||||
|
data = {
|
||||||
|
"PublicKey": public_key,
|
||||||
|
"AllowedIPs": allowed_ips
|
||||||
|
}
|
||||||
|
|
||||||
|
# First, try to update an existing peer
|
||||||
|
response = requests.put(f"{url}/{public_key}", json=data)
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
# If the peer doesn't exist, create a new one
|
||||||
|
response = requests.post(url, json=data)
|
||||||
|
|
||||||
|
if response.status_code in (200, 201):
|
||||||
|
result = response.json()
|
||||||
|
if "warning" in result:
|
||||||
|
print(f"Warning: {result['warning']}")
|
||||||
|
else:
|
||||||
|
print(result['message'])
|
||||||
|
else:
|
||||||
|
print(f"Error: {response.status_code} - {response.text}")
|
||||||
|
|
||||||
|
def delete_peer(public_key):
|
||||||
|
url = f"{BASE_URL}/peers/{public_key}"
|
||||||
|
response = requests.delete(url)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
print("Peer deleted successfully.")
|
||||||
|
else:
|
||||||
|
print(f"Error: {response.status_code} - {response.text}")
|
||||||
|
|
||||||
|
def list_peers():
|
||||||
|
url = f"{BASE_URL}/peers"
|
||||||
|
response = requests.get(url)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
peers = response.json()
|
||||||
|
print(json.dumps(peers, indent=2))
|
||||||
|
else:
|
||||||
|
print(f"Error: {response.status_code} - {response.text}")
|
||||||
|
|
||||||
|
def restore_config():
|
||||||
|
url = f"{BASE_URL}/restore"
|
||||||
|
response = requests.post(url)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
print("Configuration restored successfully.")
|
||||||
|
else:
|
||||||
|
print(f"Error: {response.status_code} - {response.text}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="WireGuard Config Manager Client")
|
||||||
|
parser.add_argument("action", choices=["create", "update", "delete", "list", "restore"], help="Action to perform")
|
||||||
|
parser.add_argument("--public-key", help="Public key of the peer")
|
||||||
|
parser.add_argument("--allowed-ips", help="Allowed IPs for the peer")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.action in ["create", "update"]:
|
||||||
|
if not args.public_key or not args.allowed_ips:
|
||||||
|
print("Error: Both --public-key and --allowed-ips are required for create/update actions.")
|
||||||
|
sys.exit(1)
|
||||||
|
create_or_update_peer(args.public_key, args.allowed_ips)
|
||||||
|
elif args.action == "delete":
|
||||||
|
if not args.public_key:
|
||||||
|
print("Error: --public-key is required for delete action.")
|
||||||
|
sys.exit(1)
|
||||||
|
delete_peer(args.public_key)
|
||||||
|
elif args.action == "list":
|
||||||
|
list_peers()
|
||||||
|
elif args.action == "restore":
|
||||||
|
restore_config()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
Loading…
Reference in New Issue
Block a user