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