Browse Source

* Possible fix for peers not updating subnet. This should finish up #11

* Initial Support for IPv6. #20
pull/29/head
Per-Arne Andersen 4 years ago
parent
commit
1eae153572
  1. 12
      README.md
  2. 24
      docker-compose.yaml
  3. 1
      docs/guides/docker_configuration.md
  4. 7
      wg_dashboard_backend/const.py
  5. 2
      wg_dashboard_backend/migrations/versions/004_create_server_subnet.py
  6. 32
      wg_dashboard_backend/migrations/versions/005_create_v6_address.py
  7. 21
      wg_dashboard_backend/migrations/versions/006_create_v6_subnet.py
  8. 3
      wg_dashboard_backend/models.py
  9. 1
      wg_dashboard_backend/requirements.txt
  10. 37
      wg_dashboard_backend/routers/v1/peer.py
  11. 30
      wg_dashboard_backend/routers/v1/server.py
  12. 3
      wg_dashboard_backend/schemas.py
  13. 13
      wg_dashboard_backend/script/wireguard.py
  14. 2
      wg_dashboard_backend/templates/peer.j2
  15. 8
      wg_dashboard_backend/templates/server.j2
  16. 3640
      wg_dashboard_frontend/package-lock.json
  17. 61
      wg_dashboard_frontend/src/app/page/dashboard/add-server/add-server.component.html
  18. 51
      wg_dashboard_frontend/src/app/page/dashboard/add-server/add-server.component.ts
  19. 2
      wg_dashboard_frontend/src/app/page/dashboard/dashboard.module.ts

12
README.md

@ -5,9 +5,11 @@ The wg-manager provides an easy-to-use graphical web interface to import, setup,
The features of wg-manager includes:
**Server**
* IPv4 **and** IPv6 support
* Create/Delete/Modify
* Start/Stop/Restart server
* Import existing
* Import existing configurations
* Export server config, along with client config as zip.
**Peer**
* Create/Delete/Modify
@ -40,10 +42,14 @@ The features of wg-manager includes:
## Method #1: Docker-compose
```yaml
version: "2.1"
services:
wireguard:
container_name: wg-manager
image: perara/wg-manager
restart: always
sysctls:
net.ipv6.conf.all.disable_ipv6: 0 # Required for IPV6
cap_add:
- NET_ADMIN
#network_mode: host # Alternatively
@ -91,6 +97,10 @@ When docker container/server has started, go to http://localhost:8888
| LOG_LEVEL | Logging level of gunicorn/python | info |
| ADMIN_USERNAME | Default admin username on database creation | admin |
| ADMIN_PASSWORD | Default admin password on database creation | admin |
| POST_UP | The POST_UP Command (version 4) | default |
| POST_DOWN | The POST_DOWN Command (version 4) | default |
| POST_UP_V6 | The POST_UP Command (version 6) | default |
| POST_DOWN_V6 | The POST_DOWN Command (version 6) | default |
# Showcase
![Illustration](docs/images/0.png)

24
docker-compose.yaml

@ -0,0 +1,24 @@
version: "2.1"
services:
server:
container_name: wg-manager
build: .
restart: always
sysctls:
net.ipv6.conf.all.disable_ipv6: 0
cap_add:
- NET_ADMIN
#network_mode: host # Alternatively
ports:
- 11820:11820/udp
- 51800-51900:51800-51900/udp
- 8888:8888
volumes:
- ./wg-manager:/config
environment:
HOST: 0.0.0.0
PORT: 8888
ADMIN_PASSWORD: admin
ADMIN_USERNAME: admin
WEB_CONCURRENCY: 2

1
docs/guides/docker_configuration.md

@ -1,6 +1,7 @@
# Docker Configuration
```bash
docker run -d \
--sysctl net.ipv6.conf.all.disable_ipv6=0 \
--cap-add NET_ADMIN \
--name wg-manager \
#--net host \

7
wg_dashboard_backend/const.py

@ -6,9 +6,10 @@ DATABASE_FILE = "/config/database.db" if IS_DOCKER else "./database.db"
DATABASE_URL = f"sqlite:///{DATABASE_FILE}"
os.makedirs("build", exist_ok=True)
DEFAULT_POST_UP = os.getenv("POST_UP", "iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE")
DEFAULT_POST_DOWN = os.getenv("POST_DOWN", "iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE")
DEFAULT_POST_UP = os.getenv("POST_UP", "iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE;")
DEFAULT_POST_DOWN = os.getenv("POST_DOWN", "iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE;")
DEFAULT_POST_UP_v6 = os.getenv("POST_UP_V6", "ip6tables -A FORWARD -i %i -j ACCEPT; ip6tables -t nat -A POSTROUTING -o eth0 -j MASQUERADE;")
DEFAULT_POST_DOWN_v6 = os.getenv("POST_DOWN_V6", "ip6tables -D FORWARD -i %i -j ACCEPT; ip6tables -t nat -D POSTROUTING -o eth0 -j MASQUERADE;")
SECRET_KEY = ''.join(random.choices(string.ascii_uppercase + string.digits, k=64))
ALGORITHM = "HS256"

2
wg_dashboard_backend/migrations/versions/004_create_server_subnet.py

@ -6,7 +6,7 @@ def upgrade(migrate_engine):
try:
meta = MetaData(bind=migrate_engine)
server = Table('server', meta, autoload=True)
subnet = Column('subnet', Text)
subnet = Column('subnet', Integer, nullable=False)
subnet.create(server)
except:
pass

32
wg_dashboard_backend/migrations/versions/005_create_v6_address.py

@ -0,0 +1,32 @@
from sqlalchemy import *
from migrate import *
def upgrade(migrate_engine):
try:
meta = MetaData(bind=migrate_engine)
server = Table('server', meta, autoload=True)
v6_address_server = Column('v6_address', VARCHAR, unique=True, nullable=True)
v6_address_server.create(server)
meta = MetaData(bind=migrate_engine)
peer = Table('peer', meta, autoload=True)
v6_address_peer = Column('v6_address', VARCHAR, nullable=True)
v6_address_peer.create(peer)
except:
pass
def downgrade(migrate_engine):
try:
meta = MetaData(bind=migrate_engine)
server = Table('server', meta, autoload=True)
server.c.v6_address.drop()
meta = MetaData(bind=migrate_engine)
peer = Table('peer', meta, autoload=True)
peer.c.v6_address.drop()
except:
pass

21
wg_dashboard_backend/migrations/versions/006_create_v6_subnet.py

@ -0,0 +1,21 @@
from sqlalchemy import *
from migrate import *
def upgrade(migrate_engine):
try:
meta = MetaData(bind=migrate_engine)
peer = Table('server', meta, autoload=True)
v6_subnet = Column('v6_subnet', INTEGER)
v6_subnet.create(peer)
except:
pass
def downgrade(migrate_engine):
try:
meta = MetaData(bind=migrate_engine)
peer = Table('server', meta, autoload=True)
peer.c.v6_subnet.drop()
except:
pass

3
wg_dashboard_backend/models.py

@ -23,6 +23,8 @@ class WGServer(Base):
interface = Column(sqlalchemy.String, unique=True, index=True)
subnet = Column(sqlalchemy.Integer, nullable=False)
address = Column(sqlalchemy.String, unique=True)
v6_address = Column(sqlalchemy.String, unique=True)
v6_subnet = Column(sqlalchemy.Integer, nullable=False)
listen_port = Column(sqlalchemy.String, unique=True)
private_key = Column(sqlalchemy.String)
public_key = Column(sqlalchemy.String)
@ -43,6 +45,7 @@ class WGPeer(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(sqlalchemy.String, default="Unnamed")
address = Column(sqlalchemy.String)
v6_address = Column(sqlalchemy.String)
public_key = Column(sqlalchemy.String)
private_key = Column(sqlalchemy.String)
shared_key = Column(sqlalchemy.Text)

1
wg_dashboard_backend/requirements.txt

@ -12,3 +12,4 @@ jinja2
sqlalchemy_utils
sqlalchemy-migrate
requests
uvicorn

37
wg_dashboard_backend/routers/v1/peer.py

@ -1,5 +1,5 @@
import ipaddress
import itertools
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
@ -13,20 +13,18 @@ import script.wireguard
router = APIRouter()
@router.post("/add", response_model=schemas.WGPeer)
def add_peer(
peer_add: schemas.WGPeerAdd,
sess: Session = Depends(middleware.get_db)
):
server = schemas.WGServer(interface=peer_add.server_interface).from_db(sess)
peer = schemas.WGPeer(server_id=server.id)
address_space = set(ipaddress.ip_network(f"{server.address}/{server.subnet}", strict=False).hosts())
def generate_ip_address(server: schemas.WGServer, v6):
if v6:
address_space = set(
itertools.islice(ipaddress.ip_network("fd42:42:42::1/64", strict=False).hosts(), 1, 1024)
)
else:
address_space = set(ipaddress.ip_network(f"{server.address}/{server.subnet}", strict=False).hosts())
occupied_space = set()
# Try add server IP to list.
try:
occupied_space.add(ipaddress.ip_address(server.address.split("/")[0]))
occupied_space.add(ipaddress.ip_address(server.v6_address if v6 else server.address))
except ValueError:
pass
@ -34,14 +32,27 @@ def add_peer(
# Try add peer ip to list.
try:
occupied_space.add(ipaddress.ip_address(p.address.split("/")[0]))
occupied_space.add(ipaddress.ip_address(p.v6_address if v6 else p.address))
except ValueError as e:
pass # Ignore invalid addresses. These are out of address_space
address_space -= occupied_space
# Select first available address
peer.address = str(list(sorted(address_space)).pop(0))
return str(list(sorted(address_space)).pop(0))
@router.post("/add", response_model=schemas.WGPeer)
def add_peer(
peer_add: schemas.WGPeerAdd,
sess: Session = Depends(middleware.get_db)
):
server = schemas.WGServer(interface=peer_add.server_interface).from_db(sess)
peer = schemas.WGPeer(server_id=server.id)
if server.v6_address:
peer.v6_address = generate_ip_address(server, v6=True)
peer.address = generate_ip_address(server, v6=False)
# Private public key generation
keys = script.wireguard.generate_keys()

30
wg_dashboard_backend/routers/v1/server.py

@ -32,15 +32,30 @@ def add_interface(
server: schemas.WGServerAdd,
sess: Session = Depends(middleware.get_db)
):
server.post_up = server.post_up if server.post_up != "" else const.DEFAULT_POST_UP
server.post_down = server.post_up if server.post_up != "" else const.DEFAULT_POST_DOWN
# Configure POST UP with defaults if not manually set.
if server.post_up == "":
server.post_up = const.DEFAULT_POST_UP
if server.v6_address is not None:
server.post_up += const.DEFAULT_POST_UP_v6
# Configure POST DOWN with defaults if not manually set.
if server.post_down == "":
server.post_down = const.DEFAULT_POST_DOWN
if server.v6_address is not None:
server.post_down += const.DEFAULT_POST_DOWN_v6
peers = server.peers if server.peers else []
# Public/Private key
try:
if server.filter_query(sess).count() != 0:
raise HTTPException(status_code=400, detail="The server interface %s already exists in the database" % server.interface)
if sess.query(models.WGServer)\
.filter(
(models.WGServer.interface == server.interface) |
(models.WGServer.address == server.address) |
(models.WGServer.v6_address == server.v6_address)).count() != 0:
raise HTTPException(status_code=400, detail="The server interface or ip %s already exists in the database" % server.interface)
if not server.private_key:
keys = script.wireguard.generate_keys()
@ -153,11 +168,14 @@ def edit_server(
peer=peer,
server=server
))
peer.sync(sess)
db_peer = models.WGPeer(**peer.dict())
sess.merge(db_peer)
sess.commit()
script.wireguard.start_interface(server)
server.is_running = script.wireguard.is_running(server)
server.sync(sess)
server.sync(sess) # TODO - fix this sync mess.
server.from_db(sess)
return server

3
wg_dashboard_backend/schemas.py

@ -114,6 +114,7 @@ class WGPeer(GenericModel):
id: int = None
name: str = None
address: str = None
v6_address: str = None
private_key: str = None
public_key: str = None
shared_key: str = None
@ -144,7 +145,9 @@ class PSK(GenericModel):
class WGServer(GenericModel):
id: int = None
address: str = None
v6_address: str = None
subnet: int = None
v6_subnet: int = None
interface: str
listen_port: int = None
endpoint: str = None

13
wg_dashboard_backend/script/wireguard.py

@ -27,6 +27,9 @@ class WGPermissionsError(Exception):
pass
class WGPortAlreadyInUse(Exception):
pass
class TempServerFile():
def __init__(self, server: schemas.WGServer):
self.server = server
@ -82,8 +85,11 @@ def start_interface(server: schemas.WGServer):
output = subprocess.check_output(const.CMD_WG_QUICK + ["up", server_file], stderr=subprocess.STDOUT)
return output
except Exception as e:
print(e.output)
if b'already exists' in e.output:
raise WGAlreadyStartedError("The wireguard device %s is already started." % server.interface)
elif b'Address already in use' in e.output:
raise WGPortAlreadyInUse("The port %s is already used by another application." % server.listen_port)
def stop_interface(server: schemas.WGServer):
@ -92,7 +98,6 @@ def stop_interface(server: schemas.WGServer):
output = subprocess.check_output(const.CMD_WG_QUICK + ["down", server_file], stderr=subprocess.STDOUT)
return output
except Exception as e:
if b'is not a WireGuard interface' in e.output:
raise WGAlreadyStoppedError("The wireguard device %s is already stopped." % server.interface)
@ -111,6 +116,7 @@ def is_running(server: schemas.WGServer):
if output is None:
return False
except Exception as e:
print(e.output)
if b'No such device' in e.output:
return False
return True
@ -197,13 +203,16 @@ def move_server_dir(interface, interface1):
def generate_config(obj: typing.Union[typing.Dict[schemas.WGPeer, schemas.WGServer], schemas.WGServer]):
if isinstance(obj, dict) and "server" in obj and "peer" in obj:
template = "peer.j2"
is_ipv6 = obj["server"].v6_address is not None
elif isinstance(obj, schemas.WGServer) or isinstance(obj, models.WGServer):
template = "server.j2"
is_ipv6 = obj.v6_address is not None
else:
raise ValueError("Incorrect input type. Should be WGPeer or WGServer")
result = util.jinja_env.get_template(template).render(
data=obj
data=obj,
is_ipv6=is_ipv6
)
return result

2
wg_dashboard_backend/templates/peer.j2

@ -1,5 +1,5 @@
[Interface]
Address = {{ data.peer.address }}/{{ data.server.subnet }}
Address = {{ data.peer.address }}/{{ data.server.subnet }}{%- if is_ipv6 -%},{{ data.peer.v6_address }}/{{ data.server.v6_subnet }}{%- endif %}
PrivateKey = {{ data.peer.private_key }}
DNS = {{ data.peer.dns }}

8
wg_dashboard_backend/templates/server.j2

@ -1,10 +1,10 @@
[Interface]
Address = {{ data.address }}/{{ data.subnet }}
Address = {{ data.address }}/{{ data.subnet }}{%- if is_ipv6 -%},{{ data.v6_address }}/{{ data.v6_subnet }}{%- endif %}
ListenPort = {{ data.listen_port }}
PrivateKey = {{ data.private_key }}
PostUp = {{ data.post_up }}
PostDown = {{ data.post_down }}
PostUp = {{ data.post_up }}{%- if is_ipv6 -%} {{ data.v6_post_up }}{%- endif %}
PostDown = {{ data.post_down }}{%- if is_ipv6 -%} {{ data.v6_post_down }}{%- endif %}
{% for peer in data.peers %}
[Peer]
@ -13,5 +13,5 @@ PublicKey = {{ peer.public_key }}
{%- if peer.shared_key %}
PresharedKey = {{ peer.shared_key }}
{%- endif %}
AllowedIPs = {{ peer.address }}/32
AllowedIPs = {{ peer.address }}/32{%- if is_ipv6 -%},{{ peer.v6_address }}/128{%- endif %}
{% endfor %}

3640
wg_dashboard_frontend/package-lock.json

File diff suppressed because it is too large

61
wg_dashboard_frontend/src/app/page/dashboard/add-server/add-server.component.html

@ -20,52 +20,91 @@
<form [formGroup]="serverForm" class="add-server-form">
<p><b>Essentials</b></p>
<table class="add-server-full-width" cellspacing="0"><tr>
<td>
<mat-form-field class="add-server-full-width">
<mat-label>Interface</mat-label>
<input formControlName="interface" matInput placeholder="wg0">
<input formControlName="interface" matInput [placeholder]="defaultInterface">
</mat-form-field>
</td>
<td>
<mat-form-field class="add-server-full-width">
<mat-label>Endpoint</mat-label>
<input formControlName="endpoint" matInput placeholder="my-address.com">
</mat-form-field>
</td>
<td>
<mat-form-field class="add-server-full-width">
<mat-label>Port</mat-label>
<input formControlName="listen_port" matInput [placeholder]="defaultListenPort">
</mat-form-field>
</td>
</tr></table>
<table class="add-server-full-width" cellspacing="0"><tr>
<td>
<mat-form-field class="add-server-full-width">
<mat-label>Address</mat-label>
<input formControlName="address" matInput placeholder="10.0.200.1">
<mat-label>IPv4 Address</mat-label>
<input formControlName="address" matInput [placeholder]="defaultIPv4Address">
</mat-form-field>
</td>
<td>
<mat-form-field matLine class="add-server-full-width">
<mat-label>Subnet</mat-label>
<select [(ngModel)]='selectedSubnet' matNativeControl formControlName="subnet">
<option *ngFor="let subnet of subnets" [value]="subnet">/{{subnet}}</option>
<select matNativeControl formControlName="subnet">
<option *ngFor="let v4Subnet of v4Subnets" [value]="v4Subnet">/{{v4Subnet}}</option>
</select>
</mat-form-field>
</td>
</tr></table>
<table class="add-server-full-width" cellspacing="0"><tr>
<td>
<mat-checkbox [checked]="true" #hasIPV6Support (change)="ipv6SupportChanged($event)">IPv6 Support</mat-checkbox>
</td>
</tr></table>
<table *ngIf="hasIPV6Support.checked" class="add-server-full-width" cellspacing="0"><tr>
<td>
<mat-form-field class="add-server-full-width">
<mat-label>Endpoint</mat-label>
<input formControlName="endpoint" matInput placeholder="my-address.com">
<mat-label>IPv6 Address</mat-label>
<input formControlName="v6_address" matInput [placeholder]="defaultIPv6Address">
</mat-form-field>
</td>
<td>
<mat-form-field class="add-server-full-width">
<mat-label>Port</mat-label>
<input formControlName="listen_port" matInput placeholder="51820">
<mat-form-field matLine class="add-server-full-width">
<mat-label>Subnet</mat-label>
<select matNativeControl formControlName="v6_subnet">
<option *ngFor="let v6Subnet of v6Subnets" [value]="v6Subnet">/{{v6Subnet}}</option>
</select>
</mat-form-field>
</td>
</tr></table>
<table class="add-server-full-width" cellspacing="0"><tr>
<td>
<mat-form-field class="add-server-full-width">
<mat-label>Default DNS</mat-label>
<input formControlName="dns" matInput placeholder="8.8.8.8">
<input formControlName="dns" matInput [placeholder]="defaultIPv4Address">
</mat-form-field>
</td>
</tr></table>

51
wg_dashboard_frontend/src/app/page/dashboard/add-server/add-server.component.ts

@ -1,4 +1,4 @@
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core';
import {Component, Input, OnInit, ViewChild, ViewEncapsulation} from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { IPValidator } from '../../../validators/ip-address.validator';
import { NumberValidator } from '../../../validators/number.validator';
@ -10,6 +10,7 @@ import {Peer} from "../../../interfaces/peer";
import {forkJoin, from} from "rxjs";
import {map, mergeMap} from "rxjs/operators";
import {NotifierService} from "angular-notifier";
import {MatCheckboxChange} from "@angular/material/checkbox";
@Component({
selector: 'app-add-server',
templateUrl: './add-server.component.html',
@ -34,8 +35,16 @@ export class AddServerComponent implements OnInit {
"DNS": "dns"
}
subnets = [];
selectedSubnet = 24;
v4Subnets = [];
v6Subnets = [];
defaultListenPort = "51820"
defaultInterface = "wg0"
defaultIPv4Subnet = 24;
defaultIPv6Subnet = 64;
defaultIPv4Address = "10.0.200.1"
defaultDNS = this.defaultIPv4Address + ",8.8.8.8"
defaultIPv6Address = "fd42:42:42::1"
serverForm: FormGroup = null;
isEdit = false;
@ -43,12 +52,14 @@ export class AddServerComponent implements OnInit {
initForm(){
this.serverForm = new FormGroup({
address: new FormControl('', [Validators.required, IPValidator.isIPAddress]),
subnet: new FormControl('', [Validators.required, Validators.min(1), Validators.max(32)]),
interface: new FormControl('', [Validators.required, Validators.minLength(3)]),
listen_port: new FormControl('', [Validators.required, NumberValidator.stringIsNumber]),
address: new FormControl(this.defaultIPv4Address, [Validators.required, IPValidator.isIPAddress]),
v6_address: new FormControl(this.defaultIPv6Address, [Validators.required, IPValidator.isIPAddress]),
subnet: new FormControl(this.defaultIPv4Subnet, [Validators.required, Validators.min(1), Validators.max(32)]),
v6_subnet: new FormControl(this.defaultIPv6Subnet, [Validators.required, Validators.min(1), Validators.max(64)]),
interface: new FormControl(this.defaultInterface, [Validators.required, Validators.minLength(3)]),
listen_port: new FormControl(this.defaultListenPort, [Validators.required, NumberValidator.stringIsNumber]),
endpoint: new FormControl('', Validators.required),
dns: new FormControl(''),
dns: new FormControl(this.defaultDNS),
private_key: new FormControl('' ),
public_key: new FormControl('' ),
post_up: new FormControl(''),
@ -61,12 +72,25 @@ export class AddServerComponent implements OnInit {
});
}
ipv6SupportChanged($event: MatCheckboxChange){
let v6AddressControl = this.serverForm.get("v6_address");
let v6SubnetControl = this.serverForm.get("v6_subnet");
if($event.checked){
v6AddressControl.enable()
v6SubnetControl.enable()
}else {
v6AddressControl.disable()
v6SubnetControl.disable()
}
}
constructor(private serverAPI: ServerService, private comm: DataService, private notify: NotifierService) {
}
ngOnInit(): void {
this.subnets = Array(32).fill(1).map((x,i)=>i+1);
this.v4Subnets = Array(32).fill(1).map((x,i)=>i+1);
this.v6Subnets = Array(64).fill(1).map((x,i)=>i+1);
this.initForm();
this.comm.on('server-edit').subscribe((data: Server) => {
@ -208,10 +232,9 @@ export class AddServerComponent implements OnInit {
});
}
this.isEdit = false;
this.editServer = null;
this.serverForm.reset();
this.serverForm.clearValidators();
this.resetForm();
}
getKeyPair() {
@ -228,7 +251,7 @@ export class AddServerComponent implements OnInit {
resetForm() {
this.isEdit = false;
this.editServer = null;
this.serverForm.clearValidators();
this.initForm()
}
}

2
wg_dashboard_frontend/src/app/page/dashboard/dashboard.module.ts

@ -19,6 +19,7 @@ import { PeerComponent } from './peer/peer.component';
import { QRCodeModule } from 'angularx-qrcode';
import {MatTooltipModule} from "@angular/material/tooltip";
import {MatSelectModule} from "@angular/material/select";
import {MatCheckboxModule} from "@angular/material/checkbox";
@NgModule({
declarations: [
@ -45,6 +46,7 @@ import {MatSelectModule} from "@angular/material/select";
QRCodeModule,
MatTooltipModule,
MatSelectModule,
MatCheckboxModule,
],
})

Loading…
Cancel
Save