wireguard: Fix issues with QR codes and config download

- Previous changes introduced a regression where QR codes and configuration
download stopped working. This is because of removal of private key from
session. Fix this.

- Provide the QR code and configuration download inline so that private key does
not have to stored in session.

- Provide separate QR code and configuration for each of the available
endpoints.

- Move the manual parameters into a separate table for each of the endpoints.
Make it look like this is only needed as the last option.

- Immediately add the client when the page is presented instead of additional
step (there is no use input needed).

- Tone down the warnings into simple text.

- Provide additional parameters in the manual configuration parameters tables to
make it easy to configure (based on the parameters configured on the WireGuard
app for Android). Use terminology that is used in app so that the fields can be
mapped easily by the user.

Tests:

- Functional tests work more reliably now.

- Clicking on the 'Add Client Automatically' creates the client successfully. A
new message is shown with client public key. The description looks good.

- Clicking on 'Download config file', 'Show QR code' and 'Show config
parameters' works. Downloaded configuration file works. QR code works too and
fills in expected parameters. Shown manual parameters are correct.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2026-10-02 07:54:12 -07:00 committed by James Valleroy
parent 176af3e85d
commit 8858774b35
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
6 changed files with 165 additions and 237 deletions

View File

@ -72,11 +72,6 @@ class AddClientForm(forms.Form):
validators=[validate_key])
class AutoAddClientForm(forms.Form):
"""Empty form for auto-client addition UX."""
pass
class AddServerForm(forms.Form):
"""Form to add server."""
peer_endpoint = forms.CharField(
@ -127,30 +122,29 @@ class AddServerForm(forms.Form):
def _build_ipv4_settings(self, iface) -> dict:
"""Build IPv4 NM settings from interfaces."""
return {
'method': 'manual',
'address': str(iface.ip),
'netmask': str(iface.netmask),
'gateway': '',
'dns': '',
'second_dns': '',
}
'method': 'manual',
'address': str(iface.ip),
'netmask': str(iface.netmask),
'gateway': '',
'dns': '',
'second_dns': '',
}
def _build_ipv6_settings(self, iface) -> dict:
"""Build IPv6 NM settings from interfaces."""
return {
'method': 'manual',
'address': str(iface.ip),
'prefix': iface.network.prefixlen,
'gateway': '',
'dns': '',
'second_dns': '',
}
'method': 'manual',
'address': str(iface.ip),
'prefix': iface.network.prefixlen,
'gateway': '',
'dns': '',
'second_dns': '',
}
def get_settings(self) -> dict[str, dict]:
"""Return NM settings dict from cleaned data."""
ip_interface = ipaddress.ip_interface(
self.cleaned_data['ip_address_and_network']
)
self.cleaned_data['ip_address_and_network'])
if self.cleaned_data['default_route']:
allowed_ips = ['0.0.0.0/0', '::/0']

View File

@ -85,18 +85,20 @@
</div>
<div class="btn-toolbar">
<a title="{% trans 'Auto add a new peer' %}"
role="button" class="btn btn-primary btn-auto-add-client"
href="{% url 'wireguard:auto-add-client' %}">
{% icon 'magic' %}
{% trans "Add Client Automatically" %}
<form method="post" action="{% url 'wireguard:auto-add-client' %}">
{% csrf_token %}
<button type="submit" title="{% trans 'Auto add a new peer' %}"
class="btn btn-primary btn-auto-add-client">
{% icon 'magic' %}
{% trans "Add Client Automatically" %}
</button>
</form>
<a title="{% trans 'Add a new peer' %}"
role="button" class="btn btn-default btn-add-client"
href="{% url 'wireguard:add-client' %}">
{% icon 'plus' %}
{% trans "Add Allowed Client" %}
</a>
<a title="{% trans 'Add a new peer' %}"
role="button" class="btn btn-default btn-add-client"
href="{% url 'wireguard:add-client' %}">
{% icon 'plus' %}
{% trans "Add Allowed Client" %}
</a>
</div>
{% else %}
<div>

View File

@ -11,78 +11,77 @@
<h3>{{ title }}</h3>
<form class="form form-auto-add-client" method="post">
{% csrf_token %}
<div class="alert alert-info alert-client-added">
{% trans "New client added with public key:" %} {{ client_pubkey }}
</div>
{{ form|bootstrap }}
{% if client_privkey %}
<div class="form-group">
<table class="table table-sm">
<tr>
<td>{% trans "IP Address" %}</td>
<td>{{ next_ip }}</td>
</tr>
<tr>
<td>{% trans "Endpoint(s)" %}</td>
<td><pre>{% for endpoint in endpoints %}{{ endpoint }}
{% endfor %}</pre></td>
</tr>
<tr>
<td>{% trans "Public Key" %}</td>
<td class="pubkey-val">{{ client_pubkey }}</td>
</tr>
<tr>
<td>{% trans "Private Key" %}</td>
<td>
<details class="privkey-val">
<summary>{% trans "Click to reveal" %}</summary>
{{ client_privkey }}
</details>
</td>
</tr>
</table>
<div class="alert alert-warning">
<strong>{% trans "Important:" %}</strong>
{% trans "Save the private key now. This page shows it only once!" %}
</div>
{% endif %}
<h4>{% trans "Client configuration file" %}</h4>
<p>
<p>
{% blocktrans trimmed %}
Download the configuration file (.conf) for manual import,
or use the QR code to import directly from your WireGuard mobile app.
To configure the client app, download the configuration file (.conf) for
import, scan the QR code, or use the parameters to configure from
scratch. This information can't be shown again. Be sure to get it before
navigating away from this page. Keep this information confidential.
{% endblocktrans %}
</p>
<p class="alert alert-warning">
<strong>{% trans "Warning:" %}</strong>
{% blocktrans trimmed %}
Treat this QR code like a password.
Anyone who scans it can gain VPN access if the connection is enabled.
{% endblocktrans %}
</p>
<div class="form-group">
<div class="btn-toolbar">
<a href="{% url 'wireguard:auto-add-client-download' %}"
class="btn btn-default">
{% icon 'download' %}
{% trans "Download config file" %}
</a>
<a href="{% url 'wireguard:auto-add-client-qr' %}"
class="btn btn-default" target="_blank">
{% icon 'qrcode' %}
{% trans "Show QR Code" %}
</a>
</div>
</p>
<div class="form-actions">
<input type="submit" class="btn btn-primary btn-auto-add-connection"
value="{% trans "Add Connection" %}"/>
<a href="{% url 'wireguard:index' %}" class="btn btn-secondary">
{% trans "Cancel" %}
{% for domain, domain_info in domains.items %}
<h4>{% trans "Using domain:" %} {{ domain }}</h4>
<div class="btn-toolbar">
<a download="wg-client.conf" href="data:text/plain;base64,{{ domain_info.config }}"
class="btn btn-default btn-download-config-file">
{% icon 'download' %}
{% trans "Download config file" %}
</a>
<button class="btn btn-default btn-show-qr-code" type="button"
data-bs-target="#qr-code-{{ domain_info.endpoint }}"
aria-expanded="false" data-bs-toggle="collapse"
aria-controls="qr-code-{{ domain_info.endpoint }}">
{% trans "Show QR code" %}
</button>
<button class="btn btn-default btn-show-config-parameters" type="button"
data-bs-target="#config-params-{{ domain_info.endpoint }}"
aria-expanded="false" data-bs-toggle="collapse"
aria-controls="config-params-{{ domain_info.endpoint }}">
{% trans "Show config parameters" %}
</button>
</div>
</form>
<div class="collapse" id="qr-code-{{ domain_info.endpoint }}">
<img src="data:image/svg+xml;base64,{{ domain_info.qr_code }}"></img>
</div>
<div class="collapse" id="config-params-{{ domain_info.endpoint }}">
<table class="table table-sm">
<tbody>
<tr>
<td>{% trans "Client Public Key" %}</td>
<td class="client-public-key">{{ client_pubkey }}</td>
</tr>
<tr>
<td>{% trans "Client Private Key" %}</td>
<td class="client-private-key">{{ client_privkey }}</td>
</tr>
<tr>
<td>{% trans "Client IP Address" %}</td>
<td>{{ next_ip }}</td>
</tr>
<tr>
<td>{% trans "Client DNS Server" %}</td>
<td>10.84.0.1</td>
</tr>
<tr>
<td>{% trans "Server/Peer Public Key" %}</td>
<td>{{ server_pubkey }}</td>
</tr>
<tr>
<td>{% trans "Server/Peer Endpoint" %}</td>
<td>{{ domain_info.endpoint }}</td>
</tr>
<tr>
<td>{% trans "Server/Peer Allowed IPs" %}</td>
<td>0.0.0.0/0,::0/0</td>
</tr>
</tbody>
</table>
</div>
{% endfor %}
{% endblock %}

View File

@ -139,35 +139,23 @@ class TestWireguardApp(functional.BaseAppTests):
with functional.wait_for_page_update(session_browser):
session_browser.find_by_css('.btn-auto-add-client').first.click()
# Verify that a new client was added
assert session_browser.find_by_css('.alert-client-added')
# Read public key
session_browser.find_by_css(
'.btn-show-config-parameters').first.click()
client_pubkey = session_browser.find_by_css(
'.pubkey-val').first.text.strip()
'.client-public-key').first.text.strip()
assert client_pubkey
# Verify private key reveal
privkey_reveal = session_browser.find_by_css('.privkey-val')
assert privkey_reveal, "Private key reveal should be present"
privkey_reveal.click()
client_privkey = session_browser.find_by_css(
'.privkey-val').text.splitlines()[1]
assert len(client_privkey) == 44, (("Private key should be base64 "
"(44 chars)"))
# Verify private key is shown
assert session_browser.find_by_css('.client-private-key').first.text
# Verify config download and QR links
download_link = session_browser.links.find_by_href(
'/freedombox/apps/wireguard/client/auto-add/action/download/')
qr_link = session_browser.links.find_by_href(
'/freedombox/apps/wireguard/client/auto-add/action/qr/')
assert download_link, "Download config link should exist"
assert qr_link, "QR code link should exist"
# Submit to add the client
with functional.wait_for_page_update(session_browser):
session_browser.find_by_css(
'.btn-auto-add-connection').first.click()
# Verify client was added successfully
assert self._client_exists(
session_browser,
client_pubkey), (("Auto-generated client should exist"))
# Assert that at least one of each buttons are present
assert session_browser.find_by_css('.btn-download-config-file')
assert session_browser.find_by_css('.btn-show-qr-code')
assert session_browser.find_by_css('.btn-show-config-parameters')
# Clean up
self._delete_client(session_browser, client_pubkey)

View File

@ -13,14 +13,8 @@ urlpatterns = [
views.EnableServerView.as_view(), name='enable-server'),
re_path(r'^apps/wireguard/client/add/$', views.AddClientView.as_view(),
name='add-client'),
re_path(r'^apps/wireguard/client/auto-add/$',
views.AutoAddClientView.as_view(), name='auto-add-client'),
re_path(r'^apps/wireguard/client/auto-add/action/download/$',
views.ClientActionsView.as_view(action='download'),
name='auto-add-client-download'),
re_path(r'^apps/wireguard/client/auto-add/action/qr/$',
views.ClientActionsView.as_view(action='qr'),
name='auto-add-client-qr'),
re_path(r'^apps/wireguard/client/auto-add/$', views.auto_add_client_view,
name='auto-add-client'),
re_path(r'^apps/wireguard/client/(?P<public_key>[^/]+)/show/$',
views.ShowClientView.as_view(), name='show-client'),
re_path(r'^apps/wireguard/client/(?P<public_key>[^/]+)/edit/$',

View File

@ -3,15 +3,18 @@
Views for WireGuard application.
"""
import base64
import urllib.parse
from io import BytesIO
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.http import Http404, HttpResponse
from django.http import Http404
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.decorators.http import require_POST
from django.views.generic import FormView, TemplateView, View
from freedombox.modules.names.components import DomainName
@ -82,114 +85,62 @@ class AddClientView(SuccessMessageMixin, FormView):
return super().form_valid(form)
class SessionClientDataMixin:
"""Shared session data loading for auto-client views."""
def get_qr_code(config: str) -> bytes:
"""Return a QR code in SVG format as a string."""
import segno
def get_session_client_data(self, request):
"""Extract client data from session."""
next_ip = request.session.get('next_ip')
pubkey = request.session.get('client_pubkey')
privkey = request.session.get('client_privkey')
endpoint = request.session.get('endpoint')
qr_code = segno.make(config)
svg_buffer = BytesIO()
qr_code.save(svg_buffer, kind='svg', light='white', scale=5)
if not all([next_ip, privkey, pubkey, endpoint]):
raise Http404("Session expired")
return svg_buffer.getvalue()
return {
'next_ip': next_ip,
'privkey': privkey,
'pubkey': pubkey,
'endpoint': endpoint
@require_POST
def auto_add_client_view(request):
"""Add a new client automatically and show all the config information."""
info = utils.get_info()
server_info = info['my_server']
if server_info:
domains = DomainName.list_names(filter_for_service='wireguard')
port = server_info.get('listen_port', 51820)
# Generate key pair
client_privkey, client_pubkey = utils.generate_client_keypair()
# Gather information
connection = utils._server_connection()
setting_name = utils.nm.SETTING_WIREGUARD_SETTING_NAME
settings = connection.get_setting_by_name(setting_name)
next_ip = utils._get_next_available_ip_address(settings)
domains_info = {}
# Prepare config and QR codes based on endpoint.
for domain in domains:
endpoint = f'{domain}:{port}'
config = utils.build_client_config(next_ip, client_privkey,
client_pubkey, endpoint)
qr_code = get_qr_code(config)
domains_info[domain] = {
'endpoint': endpoint,
'qr_code': base64.b64encode(qr_code).decode(),
'config': base64.b64encode(config.encode()).decode(),
}
def get_client_config(self, request):
"""Rebuild client config from session."""
data = self.get_session_client_data(request)
# Add a new client
utils.add_client(client_pubkey)
return utils.build_client_config(data['next_ip'], data['privkey'],
data['pubkey'], data['endpoint'])
class ClientActionsView(SessionClientDataMixin, View):
action = None
def get(self, request):
import segno
config = self.get_client_config(request)
if self.action == 'download':
response = HttpResponse(config, content_type='text/plain')
response['Content-Disposition'] = \
'attachment; filename="wg-client.conf"'
return response
elif self.action == 'qr':
qrcode = segno.make(config)
buffer = BytesIO()
qrcode.save(buffer, kind='svg', scale=5)
return HttpResponse(buffer.getvalue(),
content_type='image/svg+xml')
raise Http404("Invalid action")
class AutoAddClientView(SuccessMessageMixin, FormView):
"""View to add a client with keypair generation."""
form_class = forms.AutoAddClientForm
template_name = 'wireguard_auto_add_client.html'
success_url = reverse_lazy('wireguard:index')
success_message = _('Added new client.')
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
context = super().get_context_data(**kwargs)
context['title'] = _('Add Allowed Client')
context['domains'] = []
info = utils.get_info()
server_info = info['my_server']
if server_info:
domains = DomainName.list_names(filter_for_service='wireguard')
port = server_info.get('listen_port', 51820)
endpoints = [f'{domain}:{port}' for domain in domains]
try:
client_privkey, client_pubkey = utils.generate_client_keypair()
# Get next IP
connection = utils._server_connection()
setting_name = utils.nm.SETTING_WIREGUARD_SETTING_NAME
settings = connection.get_setting_by_name(setting_name)
next_ip = utils._get_next_available_ip_address(settings)
# Add properties to template context
context['client_pubkey'] = client_pubkey
context['client_privkey'] = client_privkey
context['next_ip'] = next_ip
context['endpoints'] = endpoints
# Store info on instance for reuse
self.request.session['client_pubkey'] = client_pubkey
except Exception as exception:
messages.warning('Client key generation failed: %s', exception)
return context
def form_valid(self, form):
"""Add client using generated public key."""
try:
client_pubkey = self.request.session.pop('client_pubkey')
utils.add_client(client_pubkey)
except KeyError:
messages.warning(self.request,
_('Session expired. Please try again.'))
return redirect('wireguard:auto-add-client')
except ValueError:
messages.warning(self.request, _('Client already exists'))
return redirect('wireguard:index')
return super().form_valid(form)
return TemplateResponse(
request, 'wireguard_auto_add_client.html', {
'title': _('Client Added'),
'server_pubkey': server_info['public_key'],
'client_pubkey': client_pubkey,
'client_privkey': client_privkey,
'next_ip': next_ip,
'domains': domains_info,
})
class ShowClientView(SuccessMessageMixin, TemplateView):