Skip to content

Commit f231c22

Browse files
authored
User Attachment Points (#306)
Allow SCIONLab users to operate attachment points and by that serve as provider for other user ASes. To run an attachment point, a static, public IP is required. User attachment points may also offer an OpenVPN server. User attachment points operate in the same way as the existing APs, which have previously been modified to fetch their configuration in a polling based model, instead of previously pushing via SSH, to make this possible. The health of attachment points is determined by checking whether they are actively polling for configuration updates. APs that fail to do so will no longer be listed. Note that this does not make any guarantees about the health of the APs SCION connectivity.
1 parent ba2c414 commit f231c22

File tree

14 files changed

+453
-13
lines changed

14 files changed

+453
-13
lines changed

.circleci/config.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
#
1717
# Check https://circleci.com/docs/2.0/language-python/ for more details
1818
#
19+
1920
version: 2.1
2021

2122
commands:
@@ -150,6 +151,7 @@ jobs:
150151
151152
- run: .circleci/setup/check-scion-connectivity.sh
152153

154+
153155
- run:
154156
name: Test TRC update and pull changes
155157
command: |

scionlab/defines.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
BW_PORT = 40001
5050
PP_PORT = 40002
5151

52+
OPENVPN_SERVER_PORT = 1194
53+
5254
DEFAULT_HOST_INTERNAL_IP = "127.0.0.1"
5355
DEFAULT_LINK_MTU = 1500 - 20 - 8
5456
DEFAULT_LINK_BANDWIDTH = 1000

scionlab/forms/attachment_conf_form.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from django.conf import settings
1919
from django.forms import BaseModelFormSet
2020
from django.core.exceptions import ValidationError
21+
from django.db.models import Case, Value, When, BooleanField
2122

2223
from crispy_forms.helper import FormHelper
2324
from crispy_forms.layout import Layout, Row, Column, Div, HTML
@@ -55,6 +56,11 @@ def _check_isd(self, forms):
5556
if len(isd_set) > 1:
5657
raise ValidationError("All attachment points must belong to the same ISD")
5758

59+
if instance and instance.is_attachment_point():
60+
for form in forms:
61+
if form.cleaned_data['attachment_point'] == instance.attachment_point_info:
62+
raise ValidationError("A link to your own AP is not allowed.")
63+
5864
if not instance:
5965
if len(isd_set) == 1:
6066
self.isd = isd_set.pop()
@@ -224,6 +230,23 @@ def __init__(self, instance, userAS, *args, **kwargs):
224230
self.disable_csrf = True
225231

226232

233+
class ProviderLinkWidget(forms.Select):
234+
"""
235+
Subclass of Django's select widget that will disable options which refer to inactive APs.
236+
"""
237+
238+
def __init__(self, attrs=None, choices=(), disabled_choices=()):
239+
super(ProviderLinkWidget, self).__init__(attrs, choices=choices)
240+
self.disabled_choices = disabled_choices
241+
242+
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
243+
option_dict = super()\
244+
.create_option(name, value, label, selected, index, subindex=subindex, attrs=attrs)
245+
if value in self.disabled_choices:
246+
option_dict['attrs']['disabled'] = 'disabled'
247+
return option_dict
248+
249+
227250
class AttachmentConfForm(forms.ModelForm):
228251
"""
229252
Form for creating and updating a Link involving a UserAS
@@ -257,7 +280,12 @@ class AttachmentConfForm(forms.ModelForm):
257280
label="Active",
258281
help_text="Activate or deactivate this connection without deleting it"
259282
)
260-
attachment_point = forms.ModelChoiceField(queryset=AttachmentPoint.objects)
283+
attachment_point = forms.ModelChoiceField(
284+
queryset=None,
285+
widget=ProviderLinkWidget(disabled_choices=[]),
286+
help_text="""Links to User Attachment Points can disappear when
287+
the corresponding Attachment Point is deleted."""
288+
)
261289

262290
class Meta:
263291
model = Link
@@ -299,6 +327,21 @@ def __init__(self, *args, **kwargs):
299327

300328
self.helper = AttachmentConfFormHelper(instance, userAS)
301329
super().__init__(*args, initial=initial, **kwargs)
330+
self.fields['attachment_point'].queryset = AttachmentPoint.objects.all().annotate(
331+
is_user_ap=Case(
332+
When(
333+
AS__owner=None,
334+
then=Value(False)
335+
),
336+
default=True,
337+
output_field=BooleanField()
338+
)
339+
).order_by('is_user_ap', '-AS__hosts__config_queried_at')
340+
disabled_choices = []
341+
for index, option in enumerate(self.fields['attachment_point'].queryset):
342+
if option.AS.owner is not None and not option.is_active():
343+
disabled_choices.append(index + 1)
344+
self.fields['attachment_point'].widget.disabled_choices = disabled_choices
302345

303346
@staticmethod
304347
def _get_formset_index(prefix):

scionlab/forms/user_as_form.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
# limitations under the License.
1414
from crispy_forms.bootstrap import AppendedText
1515
from crispy_forms.helper import FormHelper
16-
from crispy_forms.layout import Layout, Field
16+
from crispy_forms.layout import Layout, Field, Column, Row, Div
1717
from django import forms
1818
from django.forms import modelformset_factory
19+
from django.core.exceptions import ValidationError
1920

2021
from scionlab.forms.attachment_conf_form import AttachmentConfForm, AttachmentConfFormSet
2122
from scionlab.models.core import Link
@@ -36,6 +37,22 @@ def _crispy_helper(instance):
3637
'installation_type',
3738
template='scionlab/partials/installation_type_accordion.html',
3839
),
40+
Div(
41+
Div(
42+
Row(
43+
'become_user_ap',
44+
),
45+
css_class="card-header",
46+
),
47+
Div(
48+
Row(
49+
Column('public_ip', css_class='col-md-5'),
50+
Column('provide_vpn', css_class='col-md-5'),
51+
),
52+
css_class="card-body", css_id="user-ap-card-body",
53+
),
54+
css_class="card",
55+
),
3956
)
4057

4158
# We need this to render the UserASForm along with the AttachmentConfForm
@@ -73,6 +90,25 @@ class UserASForm(forms.Form):
7390
required=True,
7491
widget=forms.RadioSelect(),
7592
)
93+
become_user_ap = forms.BooleanField(
94+
required=False,
95+
label="Make this AS a publicly available Attachment Point",
96+
help_text="""If this option is enabled, this AS will show up in the list of available
97+
Attachment Points. All users will be able to create provider links to this AS.<br/> Your
98+
host will have to run the scionlab-config daemon, in order to refresh its configuration
99+
whenever other users create or modify links to your AS. Please refer to
100+
<a href='https://docs.scionlab.org' target='_blank'>Tutorials</a> for more details."""
101+
)
102+
public_ip = forms.GenericIPAddressField(
103+
required=False,
104+
label="Public IP",
105+
help_text="Public IP Address to be used for connections to child ASes"
106+
)
107+
provide_vpn = forms.BooleanField(
108+
required=False,
109+
label="Provide VPN",
110+
help_text="Allow Users to connect to your AP via VPN"
111+
)
76112

77113
def _get_attachment_conf_form_set(self, data, instance: UserAS):
78114
"""
@@ -99,20 +135,37 @@ def __init__(self, data=None, *args, **kwargs):
99135
self.instance = kwargs.pop('instance', None)
100136
self.attachment_conf_form_set = self._get_attachment_conf_form_set(data, self.instance)
101137
initial = kwargs.pop('initial', {})
138+
has_vpn = False
102139
if self.instance:
140+
host = self.instance.host
141+
is_ap = self.instance.is_attachment_point()
142+
has_vpn = is_ap and self.instance.attachment_point_info.vpn is not None
103143
initial.update({
104144
'label': self.instance.label,
105145
'installation_type': self.instance.installation_type,
146+
'public_ip': host.public_ip,
147+
'become_user_ap': is_ap,
148+
'provide_vpn': has_vpn
106149
})
107150
self.helper = _crispy_helper(self.instance)
108151
super().__init__(data, *args, initial=initial, **kwargs)
152+
if has_vpn:
153+
self.fields['provide_vpn'].widget.attrs['disabled'] = 'disabled'
109154

110155
def clean(self):
111156
cleaned_data = super().clean()
112157
if self.instance is None:
113158
self.user.check_as_quota()
114159

115160
self.attachment_conf_form_set.full_clean()
161+
162+
if cleaned_data['become_user_ap']:
163+
if not cleaned_data.get('public_ip'):
164+
self.add_error(
165+
'public_ip',
166+
ValidationError('Please enter a public IP address to become User AP')
167+
)
168+
116169
return cleaned_data
117170

118171
def has_changed(self):
@@ -139,13 +192,19 @@ def save(self, commit=True):
139192
isd=self.attachment_conf_form_set.isd,
140193
installation_type=self.cleaned_data['installation_type'],
141194
label=self.cleaned_data['label'],
195+
public_ip=self.cleaned_data['public_ip'],
196+
wants_user_ap=self.cleaned_data['become_user_ap'],
197+
wants_vpn=self.cleaned_data['provide_vpn'],
142198
)
143199
self.attachment_conf_form_set.save(user_as)
144200
return user_as
145201
else:
146202
self.instance.update(
147203
installation_type=self.cleaned_data['installation_type'],
148-
label=self.cleaned_data['label']
204+
label=self.cleaned_data['label'],
205+
public_ip=self.cleaned_data['public_ip'],
206+
wants_user_ap=self.cleaned_data['become_user_ap'],
207+
wants_vpn=self.cleaned_data['provide_vpn'],
149208
)
150209
self.attachment_conf_form_set.save(self.instance)
151210
return self.instance

scionlab/models/core.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,9 @@ def _make_master_as_key():
371371
"""
372372
return _base64encode(os.urandom(16))
373373

374+
def is_attachment_point(self):
375+
return hasattr(self, 'attachment_point_info')
376+
374377

375378
class HostManager(models.Manager):
376379
use_in_migrations = True

scionlab/models/user_as.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
"""
1919

2020
import ipaddress
21+
import datetime
2122
from typing import List, Set
2223

2324
from django import urls
25+
from django.conf import settings
2426
from django.core.exceptions import ValidationError
2527
from django.db import models
2628
from django.utils.html import format_html
@@ -33,7 +35,7 @@
3335
BorderRouter,
3436
Host
3537
)
36-
from scionlab.models.vpn import VPNClient
38+
from scionlab.models.vpn import VPN, VPNClient
3739
from scionlab.defines import (
3840
USER_AS_ID_BEGIN,
3941
USER_AS_ID_END,
@@ -49,13 +51,19 @@ def create(self,
4951
owner: User,
5052
installation_type: str,
5153
isd: int,
54+
public_ip: str = "",
55+
wants_user_ap: bool = False,
56+
wants_vpn: bool = False,
5257
as_id: str = None,
5358
label: str = None) -> 'UserAS':
5459
"""Create a UserAS
5560
5661
:param User owner: owner of this UserAS
5762
:param str installation_type:
5863
:param int isd:
64+
:param str public_ip: optional public IP address for the host of the AS
65+
:param bool wants_user_ap: optional boolean if the User AS should be AP
66+
:param str wants_vpn: optional boolean if the User AP should provide a VPN
5967
:param str as_id: optional as_id, if None is given, the next free ID is chosen
6068
:param str label: optional label
6169
@@ -82,7 +90,14 @@ def create(self,
8290

8391
user_as.generate_keys()
8492
user_as.generate_certs()
85-
user_as.init_default_services()
93+
user_as.init_default_services(public_ip=public_ip)
94+
95+
if wants_user_ap:
96+
host = user_as.hosts.first()
97+
vpn = None
98+
if wants_vpn:
99+
vpn = VPN.objects.create(server=host)
100+
AttachmentPoint.objects.create(AS=user_as, vpn=vpn)
86101

87102
return user_as
88103

@@ -133,7 +148,8 @@ class Meta:
133148
def get_absolute_url(self):
134149
return urls.reverse('user_as_detail', kwargs={'pk': self.pk})
135150

136-
def update(self, label: str, installation_type: str):
151+
def update(self, label: str, installation_type: str, public_ip: str = "",
152+
wants_user_ap: bool = False, wants_vpn: bool = False):
137153
"""
138154
Updates the `UserAS` fields and immediately saves
139155
"""
@@ -144,6 +160,29 @@ def update(self, label: str, installation_type: str):
144160
elif self.installation_type == UserAS.VM:
145161
self._unset_bind_ips_for_vagrant()
146162
self.installation_type = installation_type
163+
host = self.host
164+
host.update(public_ip=public_ip)
165+
if self.is_attachment_point():
166+
# the case has_vpn and not wants_vpn will be ignored here because it's not allowed
167+
ap = self.attachment_point_info
168+
# does the User already offer a VPN connection?
169+
has_vpn = ap.vpn is not None
170+
if not wants_user_ap:
171+
# User unchecks the 'become ap' box meaning he will not be AP anymore
172+
if has_vpn:
173+
ap.vpn.delete()
174+
ap.delete()
175+
Link.objects.filter(interfaceA__AS=self).delete()
176+
elif not has_vpn and wants_vpn:
177+
# User wants to provide a VPN for his already existing AP
178+
ap.vpn = VPN.objects.create(server=host)
179+
ap.save()
180+
elif wants_user_ap:
181+
# a new User AP will be created
182+
ap = AttachmentPoint.objects.create(AS=self)
183+
if wants_vpn:
184+
ap.vpn = VPN.objects.create(server=host)
185+
ap.save()
147186
self.save()
148187

149188
def update_attachments(self,
@@ -425,8 +464,20 @@ class AttachmentPoint(models.Model):
425464
)
426465

427466
def __str__(self):
467+
"""
468+
this representation with User prefix is expected and parsed by the frontend logic
469+
:return: string representation of an AP (with UserAP prefix if it has no owner)
470+
"""
471+
if self.AS.owner is not None:
472+
return 'UserAP: %s' % (str(self.AS))
428473
return str(self.AS)
429474

475+
def is_active(self) -> bool:
476+
threshold = datetime.datetime.utcnow() - settings.USERAP_FILTER_THRESHOLD
477+
if self.AS.hosts.first().config_queried_at is None:
478+
return False
479+
return self.AS.hosts.first().config_queried_at > threshold
480+
430481
def get_border_router_for_useras_interface(self) -> BorderRouter:
431482
"""
432483
Selects the preferred border router on which the Interfaces to UserASes should be configured

scionlab/models/vpn.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,24 @@
2929
get_cert_common_name,
3030
)
3131
from scionlab.util.django import value_set
32+
from scionlab.defines import OPENVPN_SERVER_PORT
33+
34+
35+
def find_free_subnet(supernet, prefixlen, existing):
36+
subnets = supernet.subnets(prefixlen_diff=prefixlen - supernet.prefixlen)
37+
next(subnets)
38+
for sub in subnets:
39+
if not any(sub.overlaps(ipaddress.ip_network(other)) for other in existing):
40+
return sub
41+
return None
3242

3343

3444
class VPNManager(models.Manager):
35-
def create(self, server, server_port, subnet, server_vpn_ip):
45+
def create(self, server, server_port=OPENVPN_SERVER_PORT, server_vpn_ip=None, subnet=None):
46+
subnet = subnet or str(self._find_vpn_subnet())
47+
if server_vpn_ip is None:
48+
server_vpn_ip = str(next(ipaddress.ip_network(subnet).hosts()))
49+
3650
vpn = VPN(
3751
server=server,
3852
server_port=server_port,
@@ -44,6 +58,13 @@ def create(self, server, server_port, subnet, server_vpn_ip):
4458
server.bump_config()
4559
return vpn
4660

61+
def _find_vpn_subnet(self):
62+
"""
63+
Find the next free IP subnet in form 10.10.x.0/24
64+
"""
65+
existing_vpns = value_set(VPN.objects.all(), 'subnet')
66+
return find_free_subnet(ipaddress.ip_network('10.10.0.0/16'), 24, existing_vpns)
67+
4768

4869
class VPN(models.Model):
4970
server = models.ForeignKey(

0 commit comments

Comments
 (0)