{{ config_preview }}
70 | {% endif %}
71 |
72 | | Name | 40 |{{ object.name }} | 41 |
| Slug | 44 |{{ object.slug }} | 45 |
| Tenant | 48 |49 | {% if object.tenant %} 50 | {% if object.tenant.group %} 51 | {{ object.tenant.group }} / 52 | {% endif %} 53 | {{ object.tenant }} 54 | {% else %} 55 | None 56 | {% endif %} 57 | | 58 |
{{ object.content }}
72 | {error}",
115 | "config_latest": instance.parse_last_config_fetched().__html__()
116 | if instance.last_config_fetched
117 | else None,
118 | "config_diff": diff.__html__() if diff and diff.sections else None,
119 | "config_bootstrap": bootstrap_config or f"{bootstrap_error}",
120 | }
121 |
122 |
123 | # Configuration templates
124 |
125 |
126 | class ConfigurationTemplateListView(generic.ObjectListView):
127 | queryset = ConfigurationTemplate.objects.all()
128 | filterset = filters.ConfigurationTemplateFilterSet
129 | filterset_form = forms.ConfigurationTemplateForm
130 | table = tables.ConfigurationTemplateTable
131 | template_name = "routeros/configuration_template_list.html"
132 | action_buttons = []
133 |
134 |
135 | class ConfigurationTemplateEditView(generic.ObjectEditView):
136 | queryset = ConfigurationTemplate.objects.all()
137 | model_form = forms.ConfigurationTemplateForm
138 | template_name = "routeros/configuration_template_edit.html"
139 | default_return_url = "plugins:netbox_routeros:configurationtemplate_list"
140 |
141 | def post(self, request, *args, **kwargs):
142 | if not request.POST.get("_preview"):
143 | return super().post(request, *args, **kwargs)
144 | else:
145 | return self.render_preview(request, *args, **kwargs)
146 |
147 | def render_preview(self, request, *args, **kwargs):
148 | obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
149 | form = self.model_form(data=request.POST, files=request.FILES, instance=obj)
150 |
151 | if not form.is_valid():
152 | return super().post(request, *args, **kwargs)
153 |
154 | form.instance.content = form.cleaned_data["content"]
155 | temporary_configured_device = ConfiguredDevice(
156 | device=form.cleaned_data["preview_for_device"],
157 | configuration_template=form.instance,
158 | )
159 |
160 | config_preview, error = render_configured_device_config_for_display(
161 | configured_device=temporary_configured_device,
162 | )
163 |
164 | return render(
165 | request,
166 | self.template_name,
167 | {
168 | "obj": obj,
169 | "obj_type": self.queryset.model._meta.verbose_name,
170 | "form": form,
171 | "return_url": self.get_return_url(request, obj),
172 | "config_preview": str(config_preview) if config_preview else error,
173 | },
174 | )
175 |
176 |
177 | class ConfigurationTemplateView(generic.ObjectView):
178 | queryset = ConfigurationTemplate.objects.all()
179 | template_name = "routeros/configuration_template.html"
180 |
181 |
182 | def get_template_context(device: Device):
183 | context = make_ros_config_context(device=device)
184 | context_models = {
185 | k: v for k, v in context.items() if isclass(v) and issubclass(v, Model)
186 | }
187 | context_functions = {
188 | k: v for k, v in context.items() if callable(v) and k not in context_models
189 | }
190 | context_values = {
191 | k: v
192 | for k, v in context.items()
193 | if k not in context_models and k not in context_functions
194 | }
195 |
196 | return {
197 | "context_values": pformat(context_values, sort_dicts=True),
198 | "context_functions": pformat(context_functions, sort_dicts=True),
199 | "context_models": pformat(context_models, sort_dicts=True),
200 | }
201 |
202 |
203 | def render_configured_device_config_for_display(
204 | configured_device: ConfiguredDevice,
205 | ) -> Tuple[Optional[RouterOSConfig], Optional[str]]:
206 | """Render a config for display to a user
207 |
208 | Adds some niceties around error rendering
209 | """
210 | error = None
211 | config = None
212 | try:
213 | config = configured_device.generate_config()
214 | except Exception:
215 | error = traceback.format_exc()
216 |
217 | return config, error
218 |
219 |
220 | def render_bootstrap_for_display(
221 | device: Device,
222 | ) -> Tuple[Optional[str], Optional[str]]:
223 | error = None
224 | config = None
225 | try:
226 | config = render_ros_config(device=device, template_name="bootstrap")
227 | except TemplateNotFound:
228 | # Just return None
229 | pass
230 | except Exception:
231 | error = traceback.format_exc()
232 |
233 | return config, error
234 |
--------------------------------------------------------------------------------
/netbox_routeros/ros_config_maker.py:
--------------------------------------------------------------------------------
1 | from functools import partial
2 | from ipaddress import (
3 | collapse_addresses,
4 | IPv4Interface,
5 | IPv6Address,
6 | IPv6Network,
7 | IPv4Network,
8 | ip_network,
9 | ip_interface,
10 | )
11 | from typing import Union, List, Optional
12 |
13 | import django.apps
14 | from django.contrib.postgres.fields import ArrayField
15 | from django.db.models import Func
16 | from django.db.models.functions import Cast
17 | from django.utils.module_loading import import_string
18 | from jinja2 import Environment, BaseLoader, TemplateNotFound
19 | import netaddr
20 |
21 | from dcim.models import Device, Interface
22 | from ipam.fields import IPAddressField
23 | from ipam.models import IPAddress, VLAN, Q, Prefix
24 | from utilities.utils import deepmerge
25 |
26 |
27 | class Any(Func):
28 | function = "ANY"
29 |
30 |
31 | class RosTemplateLoader(BaseLoader):
32 | def __init__(self, overrides: dict = None):
33 | self.overrides = overrides or {}
34 |
35 | def get_source(self, environment, template):
36 | from netbox_routeros.models import ConfigurationTemplate
37 |
38 | # TODO: Does not support tenants
39 | if template in self.overrides:
40 | return (
41 | self.overrides[template],
42 | template,
43 | lambda: content == self.overrides[template],
44 | )
45 |
46 | try:
47 | content = ConfigurationTemplate.objects.get(slug=template).content
48 | except ConfigurationTemplate.DoesNotExist:
49 | raise TemplateNotFound(template)
50 | else:
51 | return (
52 | content,
53 | template,
54 | lambda: content
55 | == ConfigurationTemplate.objects.get(slug=template).content,
56 | )
57 |
58 | def list_templates(self):
59 | from netbox_routeros.models import ConfigurationTemplate
60 |
61 | return ConfigurationTemplate.objects.all().values_list("slug", flat=True)
62 |
63 |
64 | def render_ros_config(
65 | device: Device,
66 | template_name: str,
67 | template_content: str = None,
68 | extra_config: str = "",
69 | ):
70 | overrides = {}
71 | if template_name and template_content:
72 | overrides[template_name] = template_content
73 | if extra_config:
74 | overrides["_extra_config"] = extra_config
75 |
76 | env = Environment(loader=RosTemplateLoader(overrides),)
77 | template = env.get_template(template_name)
78 | context = make_ros_config_context(device)
79 |
80 | config = template.render(**context)
81 |
82 | if extra_config:
83 | template = env.get_template("_extra_config")
84 | rendered_extra_config = template.render(**context)
85 | config += f"\n{rendered_extra_config}"
86 |
87 | return config
88 |
89 |
90 | def make_ros_config_context(device: Device):
91 | # Make all models available for custom querying
92 | models = {m._meta.object_name: m for m in django.apps.apps.get_models()}
93 |
94 | context = dict(
95 | device=device,
96 | vlans=_context_vlans(device),
97 | **_context_ip_addresses(device),
98 | **_context_prefixes(device),
99 | **get_template_functions(device),
100 | **models,
101 | )
102 | return dict(deepmerge(context, device.get_config_context()))
103 |
104 |
105 | def _context_ip_addresses(device: Device):
106 | # TODO: Test
107 | addresses = IPAddress.objects.filter(interface__device=device)
108 | return dict(
109 | ip_addresses=addresses,
110 | ip_addresses_v4=addresses.filter(address__family=4),
111 | ip_addresses_v6=addresses.filter(address__family=6),
112 | )
113 |
114 |
115 | def _context_vlans(device: Device):
116 | return VLAN.objects.filter(
117 | prefixes__prefix__net_contains=_any_address(device)
118 | ).distinct()
119 |
120 |
121 | def _context_prefixes(device: Device):
122 | # TODO: Test
123 | prefixes = Prefix.objects.filter(
124 | prefix__net_contains=_any_address(device)
125 | ).distinct()
126 | return dict(
127 | prefixes=prefixes,
128 | prefixes_v4=prefixes.filter(prefix__family=4),
129 | prefixes_v6=prefixes.filter(prefix__family=6),
130 | )
131 |
132 |
133 | def _any_address(device: Device):
134 | """Utility for querying against any device address"""
135 | addresses = [
136 | str(ip.ip)
137 | for ip in IPAddress.objects.filter(interface__device=device).values_list(
138 | "address", flat=True
139 | )
140 | ]
141 | addresses = Cast(addresses, output_field=ArrayField(IPAddressField()))
142 | return Any(addresses)
143 |
144 |
145 | def get_template_functions(device):
146 | return dict(
147 | get_loopback=get_loopback,
148 | get_prefix=get_prefix,
149 | combine_prefixes=combine_prefixes,
150 | get_interface=partial(get_interface, device),
151 | get_address=get_address,
152 | orm_or=orm_or,
153 | run_python_function=run_python_function,
154 | )
155 |
156 |
157 | def get_loopback(device: Device, number=1, **extra_filters) -> Optional[IPAddress]:
158 | qs = IPAddress.objects.filter(
159 | interface__device=device, role="loopback", **extra_filters
160 | ).order_by("address")
161 | try:
162 | loopback = qs[number - 1 : number].get()
163 | except IPAddress.DoesNotExist:
164 | return None
165 |
166 | if loopback:
167 | return loopback.address.ip
168 |
169 |
170 | def combine_prefixes(prefixes, only_combined=False):
171 | in_prefixes = [
172 | ip_network(p.prefix if isinstance(p, Prefix) else p) for p in prefixes
173 | ]
174 | out_prefixes = list(
175 | collapse_addresses([p for p in in_prefixes if p.version == 4])
176 | ) + list(collapse_addresses([p for p in in_prefixes if p.version == 6]))
177 |
178 | if only_combined:
179 | out_prefixes = [p for p in out_prefixes if p not in in_prefixes]
180 |
181 | # Ensure we use the netaddr IPAddress, rather than the ipaddress.IPvXNetwork
182 | return [netaddr.IPNetwork(str(p)) for p in out_prefixes]
183 |
184 |
185 | def get_interface(
186 | device: Device,
187 | obj: Union[
188 | str,
189 | IPv4Interface,
190 | IPv4Network,
191 | IPv6Address,
192 | IPv6Network,
193 | netaddr.IPNetwork,
194 | netaddr.IPAddress,
195 | IPAddress,
196 | Prefix,
197 | VLAN,
198 | ],
199 | include_vlans=True,
200 | ):
201 | if isinstance(obj, Prefix):
202 | obj = obj.prefix
203 | elif isinstance(obj, IPAddress):
204 | obj = obj.address
205 |
206 | if isinstance(obj, (str, netaddr.IPNetwork, netaddr.IPAddress)):
207 | obj = ip_interface(str(obj))
208 |
209 | if isinstance(obj, VLAN):
210 | vlan_filter = Q(untagged_vlan=obj) | Q(tagged_vlans=obj)
211 | return device.interfaces.filter(vlan_filter).first()
212 |
213 | if include_vlans:
214 | # Get the vlan interface for this IP if the router has one
215 | vlan_interface = VLAN.objects.filter(
216 | interfaces_as_tagged__device=device,
217 | prefixes__prefix__net_contains_or_equals=str(obj),
218 | ).last()
219 | if vlan_interface:
220 | return vlan_interface
221 |
222 | if obj.network.max_prefixlen == obj.network.prefixlen:
223 | # A /32 or /128, so query based on the IP host
224 | query = dict(ip_addresses__address__net_host=str(obj))
225 | else:
226 | # A subnet of some sort
227 | query = dict(ip_addresses__address__net_contained_or_equal=str(obj))
228 |
229 | # Get the smallest matching subnet
230 | return (
231 | device.interfaces.filter(**query)
232 | .order_by("ip_addresses__address__net_mask_length")
233 | .last()
234 | )
235 |
236 |
237 | def get_prefix(ip_address, **extra_filters):
238 | return (
239 | Prefix.objects.filter(
240 | prefix__net_contained_or_equal=str(ip_address), **extra_filters
241 | )
242 | .order_by("prefix__net_mask_length")
243 | .last()
244 | )
245 |
246 |
247 | def get_address(device: Device, interface: Union[Interface, VLAN], **extra_filters):
248 | if isinstance(interface, Interface):
249 | return interface.ip_addresses.filter(**extra_filters).first()
250 | else:
251 | vlan_prefixes = [str(p.prefix) for p in interface.prefixes.all()]
252 | vlan_prefixes = Cast(vlan_prefixes, output_field=ArrayField(IPAddressField()))
253 | return IPAddress.objects.filter(
254 | interface__device=device,
255 | address__net_contained_or_equal=Any(vlan_prefixes),
256 | **extra_filters,
257 | ).first()
258 |
259 |
260 | def orm_or(**filters):
261 | query = Q()
262 | for k, v in filters.items():
263 | query |= Q(**{k: v})
264 | return query
265 |
266 |
267 | def run_python_function(fn: str, *args, **kwargs):
268 | fn = import_string(fn)
269 | return fn(*args, **kwargs)
270 |
--------------------------------------------------------------------------------
/netbox_routeros/templates/routeros/configured_device.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 | {% load render_table from django_tables2 %}
3 | {% load buttons %}
4 | {% load static %}
5 | {% load helpers %}
6 | {% load plugins %}
7 |
8 | {% block header %}
9 |
52 |
53 |
54 | {% endblock %}
55 |
56 | {% block content %}
57 | 70 | Some problems were detected with this device or your configuration. 71 | You will need to correct them before you can pull 72 | from or push to the device. 73 |
74 || Device | 90 |91 | {{ object.device }} 92 | | 93 |
| Configuration template | 96 |97 | {{ object.configuration_template }} 98 | | 99 |
| Tenant | 102 |103 | {% if object.tenant %} 104 | {% if object.tenant.group %} 105 | {{ object.tenant.group }} 106 | / 107 | {% endif %} 108 | {{ object.tenant }} 109 | {% else %} 110 | None 111 | {% endif %} 112 | | 113 |
{{ object.extra_configuration }}
126 | {% else %}
127 | No extra configuration set
128 | {% endif %}
129 | 161 | The bootstrap configuration is the initial configuration which must be 162 | run on new routers. This should be as minimal as possible, and its sole 163 | purpose is to make the router accessible to Netbox. 164 |
165 |{{ config_bootstrap }}
168 | 169 | This can be changed by editing the configuration template with 170 | the slug of 'bootstrap' 171 |
172 | {% else %} 173 |174 | No bootstrap config created. 175 | Create a configuration template with the slug 'bootstrap' 176 |
177 | {% endif %} 178 |{{ context_values }}
226 | {{ context_functions }}
235 | {{ context_models }}
244 |