76 |
77 | """ # noqa: E501
78 |
79 |
80 | class DurationColumn(tables.Column):
81 | def render(self, value: int):
82 | """Value is in seconds."""
83 | return format_duration(value)
84 |
85 |
86 | class ActionsColumn(tables.TemplateColumn):
87 | def __init__(self, template: str) -> None:
88 | super().__init__(
89 | template,
90 | attrs={"td": {"class": "text-end text-nowrap noprint"}},
91 | verbose_name="",
92 | )
93 |
94 |
95 | class MonospaceColumn(tables.Column):
96 | def __init__(self, *args, additional_classes: list[str] | None = None, **kwargs):
97 | cls_str = "font-monospace"
98 | if additional_classes is not None:
99 | cls_str += " " + " ".join(additional_classes)
100 | super().__init__(*args, attrs={"td": {"class": cls_str}}, **kwargs)
101 |
102 |
103 | class ServerTable(NetBoxTable):
104 | name = tables.Column(linkify=True)
105 | dhcp6 = BooleanColumn()
106 | dhcp4 = BooleanColumn()
107 |
108 | class Meta(NetBoxTable.Meta):
109 | model = Server
110 | fields = (
111 | "pk",
112 | "name",
113 | "server_url",
114 | "username",
115 | "password",
116 | "ssl_verify",
117 | "client_cert_path",
118 | "client_key_path",
119 | "ca_file_path",
120 | "dhcp6",
121 | "dhcp4",
122 | )
123 | default_columns = ("pk", "name", "server_url", "dhcp6", "dhcp4")
124 |
125 |
126 | # we can't use NetBox table because it requires an actual model
127 | class GenericTable(BaseTable):
128 | exempt_columns = ("actions", "pk")
129 |
130 | class Meta(BaseTable.Meta):
131 | empty_text = "No rows"
132 | fields: tuple[str, ...] = ()
133 |
134 | @property
135 | def objects_count(self):
136 | return len(self.data)
137 |
138 |
139 | class SubnetTable(GenericTable):
140 | id = tables.Column(verbose_name="ID")
141 | subnet = tables.Column(
142 | linkify=lambda record, table: (
143 | (
144 | reverse(
145 | f"plugins:netbox_kea:server_leases{record['dhcp_version']}",
146 | args=[record["server_pk"]],
147 | )
148 | + "?"
149 | + urlencode({"by": "subnet", "q": record["subnet"]})
150 | )
151 | if record.get("subnet")
152 | else None
153 | ),
154 | )
155 | shared_network = tables.Column(verbose_name="Shared Network")
156 | actions = ActionsColumn(SUBNET_ACTIONS)
157 |
158 | class Meta(GenericTable.Meta):
159 | empty_text = "No subnets"
160 | fields = ("id", "subnet", "shared_network", "actions")
161 | default_columns = ("id", "subnet", "shared_network")
162 |
163 |
164 | class BaseLeaseTable(GenericTable):
165 | # This column is for the select checkboxes.
166 | pk = ToggleColumn(verbose_name="IP Address", accessor="ip_address", visible=True)
167 | ip_address = tables.Column(verbose_name="IP Address")
168 | hostname = tables.Column(verbose_name="Hostname")
169 | subnet_id = tables.Column(verbose_name="Subnet ID")
170 | hw_address = MonospaceColumn(verbose_name="Hardware Address")
171 | valid_lft = DurationColumn(verbose_name="Valid Lifetime")
172 | cltt = columns.DateTimeColumn(verbose_name="Client Last Transaction Time")
173 | expires_at = columns.DateTimeColumn(verbose_name="Expires At")
174 | expires_in = DurationColumn(verbose_name="Expires In")
175 | actions = ActionsColumn(LEASE_ACTIONS)
176 |
177 | class Meta(GenericTable.Meta):
178 | empty_text = "No leases found."
179 | fields = (
180 | "ip_address",
181 | "hostname",
182 | "subnet_id",
183 | "hw_address",
184 | "valid_lft",
185 | "cltt",
186 | "expires_at",
187 | "expires_in",
188 | "actions",
189 | )
190 | default_columns = ("ip_address", "hostname")
191 |
192 |
193 | class LeaseTable4(BaseLeaseTable):
194 | client_id = tables.Column(verbose_name="Client ID")
195 |
196 | class Meta(BaseLeaseTable.Meta):
197 | fields = ("client_id", *BaseLeaseTable.Meta.fields)
198 |
199 |
200 | class LeaseTable6(BaseLeaseTable):
201 | type = tables.Column(verbose_name="Type", accessor="type")
202 | preferred_lft = DurationColumn(verbose_name="Preferred Lifetime")
203 | duid = MonospaceColumn(verbose_name="DUID", additional_classes=["text-break"])
204 | iaid = MonospaceColumn(verbose_name="IAID")
205 |
206 | class Meta(BaseLeaseTable.Meta):
207 | fields = ("type", "duid", "iaid", *BaseLeaseTable.Meta.fields)
208 |
209 |
210 | class LeaseDeleteTable(GenericTable):
211 | ip_address = tables.Column(verbose_name="IP Address", accessor="ip")
212 |
213 | class Meta(NetBoxTable.Meta):
214 | empty_text = "No leases"
215 | fields = ("ip_address",)
216 | default_columns = ("ip_address",)
217 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright [yyyy] [name of copyright owner]
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
203 |
--------------------------------------------------------------------------------
/tests/docker/kea_configs/kea-dhcp6.conf:
--------------------------------------------------------------------------------
1 | // This is a basic configuration for the Kea DHCPv6 server. Subnet declarations
2 | // are mostly commented out and no interfaces are listed. Therefore, the servers
3 | // will not listen or respond to any queries.
4 | // The basic configuration must be extended to specify interfaces on which
5 | // the servers should listen. There are a number of example options defined.
6 | // These probably don't make any sense in your network. Make sure you at least
7 | // update the following, before running this example in your network:
8 | // - change the network interface names
9 | // - change the subnets to match your actual network
10 | // - change the option values to match your network
11 | //
12 | // This is just a very basic configuration. Kea comes with large suite (over 30)
13 | // of configuration examples and extensive Kea User's Guide. Please refer to
14 | // those materials to get better understanding of what this software is able to
15 | // do. Comments in this configuration file sometimes refer to sections for more
16 | // details. These are section numbers in Kea User's Guide. The version matching
17 | // your software should come with your Kea package, but it is also available
18 | // in ISC's Knowledgebase (https://kea.readthedocs.io; the direct link for
19 | // the stable version is https://kea.readthedocs.io/).
20 | //
21 | // This configuration file contains only DHCPv6 server's configuration.
22 | // If configurations for other Kea services are also included in this file they
23 | // are ignored by the DHCPv6 server.
24 | {
25 |
26 | // DHCPv6 configuration starts here. This section will be read by DHCPv6 server
27 | // and will be ignored by other components.
28 | "Dhcp6": {
29 | // Add names of your network interfaces to listen on.
30 | "interfaces-config": {
31 | // You typically want to put specific interface names here, e.g. eth0
32 | // but you can also specify unicast addresses (e.g. eth0/2001:db8::1) if
33 | // you want your server to handle unicast traffic in addition to
34 | // multicast. (DHCPv6 is a multicast based protocol).
35 | "interfaces": [ ]
36 | },
37 |
38 | // Kea supports control channel, which is a way to receive management commands
39 | // while the server is running. This is a Unix domain socket that receives
40 | // commands formatted in JSON, e.g. config-set (which sets new configuration),
41 | // config-reload (which tells Kea to reload its configuration from file),
42 | // statistic-get (to retrieve statistics) and many more. For detailed
43 | // description, see Sections 9.12, 16 and 15.
44 | "control-socket": {
45 | "socket-type": "unix",
46 | "socket-name": "/run/kea/kea-dhcp6-ctrl.sock"
47 | },
48 |
49 | // Use Memfile lease database backend to store leases in a CSV file.
50 | // Depending on how Kea was compiled, it may also support SQL databases
51 | // (MySQL and/or PostgreSQL). Those database backends require more
52 | // parameters, like name, host and possibly user and password.
53 | // There are dedicated examples for each backend. See Section 8.2.2 "Lease
54 | // Storage" for details.
55 | "lease-database": {
56 | // Memfile is the simplest and easiest backend to use. It's an in-memory
57 | // C++ database that stores its state in CSV file.
58 | "type": "memfile",
59 | "lfc-interval": 3600
60 | },
61 |
62 | // Kea allows storing host reservations in a database. If your network is
63 | // small or you have few reservations, it's probably easier to keep them
64 | // in the configuration file. If your network is large, it's usually better
65 | // to use database for it. To enable it, uncomment the following:
66 | // "hosts-database": {
67 | // "type": "mysql",
68 | // "name": "kea",
69 | // "user": "kea",
70 | // "password": "kea",
71 | // "host": "localhost",
72 | // "port": 3306
73 | // },
74 | // See Section 8.2.3 "Hosts storage" for details.
75 |
76 | // Setup reclamation of the expired leases and leases affinity.
77 | // Expired leases will be reclaimed every 10 seconds. Every 25
78 | // seconds reclaimed leases, which have expired more than 3600
79 | // seconds ago, will be removed. The limits for leases reclamation
80 | // are 100 leases or 250 ms for a single cycle. A warning message
81 | // will be logged if there are still expired leases in the
82 | // database after 5 consecutive reclamation cycles.
83 | "expired-leases-processing": {
84 | "reclaim-timer-wait-time": 10,
85 | "flush-reclaimed-timer-wait-time": 25,
86 | "hold-reclaimed-time": 3600,
87 | "max-reclaim-leases": 100,
88 | "max-reclaim-time": 250,
89 | "unwarned-reclaim-cycles": 5
90 | },
91 |
92 | // These parameters govern global timers. Addresses will be assigned with
93 | // preferred and valid lifetimes being 3000 and 4000, respectively. Client
94 | // is told to start renewing after 1000 seconds. If the server does not
95 | // respond after 2000 seconds since the lease was granted, a client is
96 | // supposed to start REBIND procedure (emergency renewal that allows
97 | // switching to a different server).
98 | "renew-timer": 1000,
99 | "rebind-timer": 2000,
100 | "preferred-lifetime": 3000,
101 | "valid-lifetime": 4000,
102 |
103 | // These are global options. They are going to be sent when a client requests
104 | // them, unless overwritten with values in more specific scopes. The scope
105 | // hierarchy is:
106 | // - global
107 | // - subnet
108 | // - class
109 | // - host
110 | //
111 | // Not all of those options make sense. Please configure only those that
112 | // are actually useful in your network.
113 | //
114 | // For a complete list of options currently supported by Kea, see
115 | // Section 8.2.9 "Standard DHCPv6 Options". Kea also supports
116 | // vendor options (see Section 7.2.10) and allows users to define their
117 | // own custom options (see Section 7.2.9).
118 | "option-data": [
119 | // When specifying options, you typically need to specify
120 | // one of (name or code) and data. The full option specification
121 | // covers name, code, space, csv-format and data.
122 | // space defaults to "dhcp6" which is usually correct, unless you
123 | // use encapsulate options. csv-format defaults to "true", so
124 | // this is also correct, unless you want to specify the whole
125 | // option value as long hex string. For example, to specify
126 | // domain-name-servers you could do this:
127 | // {
128 | // "name": "dns-servers",
129 | // "code": 23,
130 | // "csv-format": "true",
131 | // "space": "dhcp6",
132 | // "data": "2001:db8:2::45, 2001:db8:2::100"
133 | // }
134 | // but it's a lot of writing, so it's easier to do this instead:
135 | {
136 | "name": "dns-servers",
137 | "data": "2001:db8:2::45, 2001:db8:2::100"
138 | },
139 |
140 | // Typically people prefer to refer to options by their names, so they
141 | // don't need to remember the code names. However, some people like
142 | // to use numerical values. For example, DHCPv6 can optionally use
143 | // server unicast communication, if extra option is present. Option
144 | // "unicast" uses option code 12, so you can reference to it either
145 | // by "name": "unicast" or "code": 12. If you enable this option,
146 | // you really should also tell the server to listen on that address
147 | // (see interfaces-config/interfaces list above).
148 | {
149 | "code": 12,
150 | "data": "2001:db8::1"
151 | },
152 |
153 | // String options that have a comma in their values need to have
154 | // it escaped (i.e. each comma is preceded by two backslashes).
155 | // That's because commas are reserved for separating fields in
156 | // compound options. At the same time, we need to be conformant
157 | // with JSON spec, that does not allow "\,". Therefore the
158 | // slightly uncommon double backslashes notation is needed.
159 |
160 | // Legal JSON escapes are \ followed by "\/bfnrt character
161 | // or \u followed by 4 hexadecimal numbers (currently Kea
162 | // supports only \u0000 to \u00ff code points).
163 | // CSV processing translates '\\' into '\' and '\,' into ','
164 | // only so for instance '\x' is translated into '\x'. But
165 | // as it works on a JSON string value each of these '\'
166 | // characters must be doubled on JSON input.
167 | {
168 | "name": "new-posix-timezone",
169 | "data": "EST5EDT4\\,M3.2.0/02:00\\,M11.1.0/02:00"
170 | },
171 |
172 | // Options that take integer values can either be specified in
173 | // dec or hex format. Hex format could be either plain (e.g. abcd)
174 | // or prefixed with 0x (e.g. 0xabcd).
175 | {
176 | "name": "preference",
177 | "data": "0xf0"
178 | },
179 |
180 | // A few options are encoded in (length, string) tuples
181 | // which can be defined using only strings as the CSV
182 | // processing computes lengths.
183 | {
184 | "name": "bootfile-param",
185 | "data": "root=/dev/sda2, quiet, splash"
186 | }
187 | ],
188 |
189 | // Another thing possible here are hooks. Kea supports a powerful mechanism
190 | // that allows loading external libraries that can extract information and
191 | // even influence how the server processes packets. Those libraries include
192 | // additional forensic logging capabilities, ability to reserve hosts in
193 | // more flexible ways, and even add extra commands. For a list of available
194 | // hook libraries, see https://gitlab.isc.org/isc-projects/kea/wikis/Hooks-available.
195 | "hooks-libraries": [
196 | {"library": "/usr/lib/kea/hooks/libdhcp_lease_cmds.so"},
197 | {"library": "/usr/lib/kea/hooks/libdhcp_stat_cmds.so"}
198 | ],
199 | // {
200 | // // Forensic Logging library generates forensic type of audit trail
201 | // // of all devices serviced by Kea, including their identifiers
202 | // // (like MAC address), their location in the network, times
203 | // // when they were active etc.
204 | // "library": "/usr/lib/kea/hooks/libdhcp_legal_log.so",
205 | // "parameters": {
206 | // "path": "/var/lib/kea",
207 | // "base-name": "kea-forensic6"
208 | // }
209 | // },
210 | // {
211 | // // Flexible identifier (flex-id). Kea software provides a way to
212 | // // handle host reservations that include addresses, prefixes,
213 | // // options, client classes and other features. The reservation can
214 | // // be based on hardware address, DUID, circuit-id or client-id in
215 | // // DHCPv4 and using hardware address or DUID in DHCPv6. However,
216 | // // there are sometimes scenario where the reservation is more
217 | // // complex, e.g. uses other options that mentioned above, uses part
218 | // // of specific options or perhaps even a combination of several
219 | // // options and fields to uniquely identify a client. Those scenarios
220 | // // are addressed by the Flexible Identifiers hook application.
221 | // "library": "/usr/lib/kea/hooks/libdhcp_flex_id.so",
222 | // "parameters": {
223 | // "identifier-expression": "relay6[0].option[37].hex"
224 | // }
225 | // }
226 | // ],
227 |
228 | // Below an example of a simple IPv6 subnet declaration. Uncomment to enable
229 | // it. This is a list, denoted with [ ], of structures, each denoted with
230 | // { }. Each structure describes a single subnet and may have several
231 | // parameters. One of those parameters is "pools" that is also a list of
232 | // structures.
233 | "subnet6": [
234 | {
235 | // This defines the whole subnet. Kea will use this information to
236 | // determine where the clients are connected. This is the whole
237 | // subnet in your network. This is mandatory parameter for each
238 | // subnet.
239 | "subnet": "2001:db8:1::/64",
240 | "id": 1,
241 |
242 | // Pools define the actual part of your subnet that is governed
243 | // by Kea. Technically this is optional parameter, but it's
244 | // almost always needed for DHCP to do its job. If you omit it,
245 | // clients won't be able to get addresses, unless there are
246 | // host reservations defined for them.
247 | "pools": [ { "pool": "2001:db8:1::/80" } ],
248 |
249 | // Kea supports prefix delegation (PD). This mechanism delegates
250 | // whole prefixes, instead of single addresses. You need to specify
251 | // a prefix and then size of the delegated prefixes that it will
252 | // be split into. This example below tells Kea to use
253 | // 2001:db8:1::/56 prefix as pool and split it into /64 prefixes.
254 | // This will give you 256 (2^(64-56)) prefixes.
255 | "pd-pools": [
256 | {
257 | "prefix": "2001:db8:8::",
258 | "prefix-len": 56,
259 | "delegated-len": 64
260 |
261 | // Kea also supports excluded prefixes. This advanced option
262 | // is explained in Section 9.2.9. Please make sure your
263 | // excluded prefix matches the pool it is defined in.
264 | // "excluded-prefix": "2001:db8:8:0:80::",
265 | // "excluded-prefix-len": 72
266 | }
267 | ],
268 | "option-data": [
269 | // You can specify additional options here that are subnet
270 | // specific. Also, you can override global options here.
271 | {
272 | "name": "dns-servers",
273 | "data": "2001:db8:2::dead:beef, 2001:db8:2::cafe:babe"
274 | }
275 | ],
276 |
277 | // Host reservations can be defined for each subnet.
278 | //
279 | // Note that reservations are subnet-specific in Kea. This is
280 | // different than ISC DHCP. Keep that in mind when migrating
281 | // your configurations.
282 | "reservations": [
283 | // This is a simple host reservation. The host with DUID matching
284 | // the specified value will get an address of 2001:db8:1::100.
285 | {
286 | "duid": "01:02:03:04:05:0A:0B:0C:0D:0E",
287 | "ip-addresses": [ "2001:db8:1::100" ]
288 | },
289 |
290 | // This is similar to the previous one, but this time the
291 | // reservation is done based on hardware/MAC address. The server
292 | // will do its best to extract the hardware/MAC address from
293 | // received packets (see 'mac-sources' directive for
294 | // details). This particular reservation also specifies two
295 | // extra options to be available for this client. If there are
296 | // options with the same code specified in a global, subnet or
297 | // class scope, the values defined at host level take
298 | // precedence.
299 | {
300 | "hw-address": "00:01:02:03:04:05",
301 | "ip-addresses": [ "2001:db8:1::101" ],
302 | "option-data": [
303 | {
304 | "name": "dns-servers",
305 | "data": "3000:1::234"
306 | },
307 | {
308 | "name": "nis-servers",
309 | "data": "3000:1::234"
310 | }],
311 |
312 | // This client will be automatically added to certain
313 | // classes.
314 | "client-classes": [ "special_snowflake", "office" ]
315 | },
316 |
317 | // This is a bit more advanced reservation. The client with the
318 | // specified DUID will get a reserved address, a reserved prefix
319 | // and a hostname. This reservation is for an address that it
320 | // not within the dynamic pool. Finally, this reservation
321 | // features vendor specific options for CableLabs, which happen
322 | // to use enterprise-id 4491. Those particular values will be
323 | // returned only to the client that has a DUID matching this
324 | // reservation.
325 | {
326 | "duid": "01:02:03:04:05:06:07:08:09:0A",
327 | "ip-addresses": [ "2001:db8:1:0:cafe::1" ],
328 | "prefixes": [ "2001:db8:2:abcd::/64" ],
329 | "hostname": "foo.example.com",
330 | "option-data": [
331 | {
332 | "name": "vendor-opts",
333 | "data": "4491"
334 | },
335 | {
336 | "name": "tftp-servers",
337 | "space": "vendor-4491",
338 | "data": "3000:1::234"
339 | }
340 | ]
341 | },
342 |
343 | // This reservation is using flexible identifier. Instead of
344 | // relying on specific field, sysadmin can define an expression
345 | // similar to what is used for client classification,
346 | // e.g. substring(relay[0].option[17],0,6). Then, based on the
347 | // value of that expression for incoming packet, the reservation
348 | // is matched. Expression can be specified either as hex or
349 | // plain text using single quotes.
350 |
351 | // Note: flexible identifier requires flex_id hook library to be
352 | // loaded to work.
353 | {
354 | "flex-id": "'somevalue'",
355 | "ip-addresses": [ "2001:db8:1:0:cafe::2" ]
356 | }
357 | ]
358 | }
359 | // More subnets can be defined here.
360 | // {
361 | // "subnet": "2001:db8:2::/64",
362 | // "pools": [ { "pool": "2001:db8:2::/80" } ]
363 | // },
364 | // {
365 | // "subnet": "2001:db8:3::/64",
366 | // "pools": [ { "pool": "2001:db8:3::/80" } ]
367 | // },
368 | // {
369 | // "subnet": "2001:db8:4::/64",
370 | // "pools": [ { "pool": "2001:db8:4::/80" } ]
371 | // }
372 | ],
373 |
374 | "shared-networks": [
375 | {
376 | "name": "test-shared-network-6",
377 | "subnet6": [
378 | {
379 | "id": 2,
380 | "subnet": "2001:db8:2::/64",
381 | "pools": [ { "pool": "2001:db8:2::/64" } ]
382 | }
383 | ]
384 | }
385 | ],
386 |
387 | // Client-classes can be defined here. See "client-classes" in Dhcp4 for
388 | // an example.
389 |
390 | // DDNS information (how the DHCPv6 component can reach a DDNS daemon)
391 |
392 | // Logging configuration starts here. Kea uses different loggers to log various
393 | // activities. For details (e.g. names of loggers), see Chapter 18.
394 | "loggers": [
395 | {
396 | // This specifies the logging for kea-dhcp6 logger, i.e. all logs
397 | // generated by Kea DHCPv6 server.
398 | "name": "kea-dhcp6",
399 | "output_options": [
400 | {
401 | // Specifies the output file. There are several special values
402 | // supported:
403 | // - stdout (prints on standard output)
404 | // - stderr (prints on standard error)
405 | // - syslog (logs to syslog)
406 | // - syslog:name (logs to syslog using specified name)
407 | // Any other value is considered a name of the file
408 | "output": "stdout",
409 |
410 | // Shorter log pattern suitable for use with systemd,
411 | // avoids redundant information
412 | // "pattern": "%-5p %m\n",
413 |
414 | // This governs whether the log output is flushed to disk after
415 | // every write.
416 | // "flush": false,
417 |
418 | // This specifies the maximum size of the file before it is
419 | // rotated.
420 | // "maxsize": 1048576,
421 |
422 | // This specifies the maximum number of rotated files to keep.
423 | // "maxver": 8
424 | }
425 | ],
426 | // This specifies the severity of log messages to keep. Supported values
427 | // are: FATAL, ERROR, WARN, INFO, DEBUG
428 | "severity": "INFO",
429 |
430 | // If DEBUG level is specified, this value is used. 0 is least verbose,
431 | // 99 is most verbose. Be cautious, Kea can generate lots and lots
432 | // of logs if told to do so.
433 | "debuglevel": 0
434 | }
435 | ]
436 | }
437 | }
438 |
--------------------------------------------------------------------------------
/netbox_kea/views.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from abc import ABCMeta
3 | from typing import Any, Generic, TypeVar
4 |
5 | from django.contrib import messages
6 | from django.http import HttpResponse, HttpResponseForbidden
7 | from django.http.request import HttpRequest
8 | from django.shortcuts import redirect, render
9 | from django.urls import reverse
10 | from netaddr import IPAddress, IPNetwork
11 | from netbox.tables import BaseTable
12 | from netbox.views import generic
13 | from utilities.exceptions import AbortRequest
14 | from utilities.htmx import htmx_partial
15 | from utilities.paginator import EnhancedPaginator, get_paginate_count
16 | from utilities.views import GetReturnURLMixin, ViewTab, register_model_view
17 |
18 | from . import constants, forms, tables
19 | from .filtersets import ServerFilterSet
20 | from .kea import KeaClient
21 | from .models import Server
22 | from .utilities import (
23 | OptionalViewTab,
24 | check_dhcp_enabled,
25 | export_table,
26 | format_duration,
27 | format_leases,
28 | )
29 |
30 | T = TypeVar("T", bound=BaseTable)
31 |
32 |
33 | @register_model_view(Server)
34 | class ServerView(generic.ObjectView):
35 | queryset = Server.objects.all()
36 |
37 |
38 | @register_model_view(Server, "edit")
39 | class ServerEditView(generic.ObjectEditView):
40 | queryset = Server.objects.all()
41 | form = forms.ServerForm
42 |
43 |
44 | @register_model_view(Server, "delete")
45 | class ServerDeleteView(generic.ObjectDeleteView):
46 | queryset = Server.objects.all()
47 |
48 |
49 | class ServerListView(generic.ObjectListView):
50 | queryset = Server.objects.all()
51 | table = tables.ServerTable
52 | filterset = ServerFilterSet
53 | filterset_form = forms.ServerFilterForm
54 |
55 |
56 | class ServerBulkDeleteView(generic.BulkDeleteView):
57 | queryset = Server.objects.all()
58 | table = tables.ServerTable
59 |
60 |
61 | @register_model_view(Server, "status")
62 | class ServerStatusView(generic.ObjectView):
63 | queryset = Server.objects.all()
64 | tab = ViewTab(label="Status", weight=1000)
65 | template_name = "netbox_kea/server_status.html"
66 |
67 | def _get_ca_status(self, client: KeaClient) -> dict[str, Any]:
68 | """Get the control agent status"""
69 | status = client.command("status-get")
70 | args = status[0]["arguments"]
71 | assert args is not None
72 |
73 | version = client.command("version-get")
74 | version_args = version[0]["arguments"]
75 | assert version_args is not None
76 |
77 | return {
78 | "PID": args["pid"],
79 | "Uptime": format_duration(int(args["uptime"])),
80 | "Time since reload": format_duration(int(args["reload"])),
81 | "Version": version_args["extended"],
82 | }
83 |
84 | def _get_dhcp_status(
85 | self, server: Server, client: KeaClient
86 | ) -> dict[str, dict[str, Any]]:
87 | resp: dict[str, dict[str, Any]] = {}
88 |
89 | # Map of name to pretty name
90 | service_names = {"dhcp6": "DHCPv6", "dhcp4": "DHCPv4"}
91 | services = []
92 | if server.dhcp6:
93 | services.append("dhcp6")
94 | if server.dhcp4:
95 | services.append("dhcp4")
96 | service_keys = list(services)
97 |
98 | dhcp_status = client.command("status-get", service=service_keys)
99 | dhcp_version = client.command("version-get", service=service_keys)
100 | assert len(dhcp_status) == len(services)
101 | assert len(dhcp_version) == len(services)
102 | for svc, status, version in zip(services, dhcp_status, dhcp_version):
103 | args = status["arguments"]
104 | assert args is not None
105 |
106 | version_args = version["arguments"]
107 | assert version_args is not None
108 |
109 | resp[service_names[svc]] = {
110 | "PID": args["pid"],
111 | "Uptime": format_duration(args["uptime"]),
112 | "Time since reload": format_duration(int(args["reload"])),
113 | "Version": version_args["extended"],
114 | }
115 |
116 | if (ha := args.get("high-availability")) is not None:
117 | # https://kea.readthedocs.io/en/latest/arm/hooks.html#load-balancing-configuration
118 | # Note that while the top-level parameter high-availability is a list,
119 | # only a single entry is currently supported.
120 |
121 | ha_servers = ha[0].get("ha-servers")
122 | ha_local = ha_servers.get("local", {})
123 | ha_remote = ha_servers.get("remote", {})
124 | resp[service_names[svc]].update(
125 | {
126 | "HA mode": ha[0].get("ha-mode"),
127 | "HA local role": ha_local.get("role"),
128 | "HA local state": ha_local.get("state"),
129 | "HA remote connection interrupted": str(
130 | ha_remote.get("connection-interrupted")
131 | ),
132 | "HA remote age (seconds)": ha_remote.get("age"),
133 | "HA remote role": ha_remote.get("role"),
134 | "HA remote last state": ha_remote.get("last-state"),
135 | "HA remote in touch": ha_remote.get("in-touch"),
136 | "HA remote unacked clients": ha_remote.get("unacked-clients"),
137 | "HA remote unacked clients left": ha_remote.get(
138 | "unacked-clients-left"
139 | ),
140 | "HA remote connecting clients": ha_remote.get(
141 | "connecting-clients"
142 | ),
143 | }
144 | )
145 | return resp
146 |
147 | def _get_statuses(
148 | self, server: Server, client: KeaClient
149 | ) -> dict[str, dict[str, Any]]:
150 | return {
151 | "Control Agent": self._get_ca_status(client),
152 | **self._get_dhcp_status(server, client),
153 | }
154 |
155 | def get_extra_context(
156 | self, request: HttpResponse, instance: Server
157 | ) -> dict[str, Any]:
158 | return {"statuses": self._get_statuses(instance, instance.get_client())}
159 |
160 |
161 | class BaseServerLeasesView(generic.ObjectView, Generic[T]):
162 | template_name = "netbox_kea/server_dhcp_leases.html"
163 | queryset = Server.objects.all()
164 | table: type[T]
165 |
166 | def get_table(self, data: list[dict[str, Any]], request: HttpRequest) -> T:
167 | table = self.table(data, user=request.user)
168 | table.configure(request)
169 | return table
170 |
171 | def get_leases_page(
172 | self, client: KeaClient, subnet: IPNetwork, page: str | None, per_page: int
173 | ) -> tuple[list[dict[str, Any]], str | None]:
174 | if page:
175 | frm = page
176 | elif int(subnet.network) == 0:
177 | frm = str(subnet.network)
178 | else:
179 | frm = str(subnet.network - 1)
180 |
181 | resp = client.command(
182 | f"lease{self.dhcp_version}-get-page",
183 | service=[f"dhcp{self.dhcp_version}"],
184 | arguments={"from": frm, "limit": per_page},
185 | check=(0, 3),
186 | )
187 |
188 | if resp[0]["result"] == 3:
189 | return [], None
190 |
191 | args = resp[0]["arguments"]
192 | assert args is not None
193 |
194 | raw_leases = args["leases"]
195 | next = f"{raw_leases[-1]['ip-address']}" if args["count"] == per_page else None
196 | for i, lease in enumerate(raw_leases):
197 | lease_ip = IPAddress(lease["ip-address"])
198 | if lease_ip not in subnet:
199 | raw_leases = raw_leases[:i]
200 | next = None
201 | break
202 |
203 | subnet_leases = format_leases(raw_leases)
204 |
205 | return subnet_leases, next
206 |
207 | def get_leases(self, client: KeaClient, q: Any, by: str) -> list[dict[str, Any]]:
208 | arguments: dict[str, Any]
209 | command = ""
210 | multiple = True
211 |
212 | if by == constants.BY_IP:
213 | arguments = {"ip-address": q}
214 | multiple = False
215 | elif by == constants.BY_HW_ADDRESS:
216 | arguments = {"hw-address": q}
217 | command = "-by-hw-address"
218 | elif by == constants.BY_HOSTNAME:
219 | arguments = {"hostname": q}
220 | command = "-by-hostname"
221 | elif by == constants.BY_CLIENT_ID:
222 | arguments = {"client-id": q}
223 | command = "-by-client-id"
224 | elif by == constants.BY_SUBNET_ID:
225 | command = "-all"
226 | arguments = {"subnets": [int(q)]}
227 | elif by == constants.BY_DUID:
228 | command = "-by-duid"
229 | arguments = {"duid": q}
230 | else:
231 | # We should never get here because the
232 | # form should of been validated.
233 | raise AbortRequest(f"Invalid search by (this shouldn't happen): {by}")
234 | resp = client.command(
235 | f"lease{self.dhcp_version}-get{command}",
236 | service=[f"dhcp{self.dhcp_version}"],
237 | arguments=arguments,
238 | check=(0, 3),
239 | )
240 |
241 | if resp[0]["result"] == 3:
242 | return []
243 |
244 | args = resp[0]["arguments"]
245 | assert args is not None
246 | if multiple is True:
247 | return format_leases(args["leases"])
248 | return format_leases([args])
249 |
250 | def get_extra_context(
251 | self, request: HttpRequest, _instance: Server
252 | ) -> dict[str, Any]:
253 | # For non-htmx requests.
254 |
255 | table = self.get_table([], request)
256 | form = self.form(request.GET) if "q" in request.GET else self.form()
257 | return {"form": form, "table": table}
258 |
259 | def get_export(self, request: HttpRequest, **kwargs) -> HttpResponse:
260 | form = self.form(request.GET)
261 | if not form.is_valid():
262 | messages.warning(request, "Invalid form for export.")
263 | return redirect(request.path)
264 |
265 | instance = self.get_object(**kwargs)
266 |
267 | by = form.cleaned_data["by"]
268 | q = form.cleaned_data["q"]
269 | client = instance.get_client()
270 | if by == constants.BY_SUBNET:
271 | leases = []
272 | page: str | None = "" # start from the beginning
273 | while page is not None:
274 | page_leases, page = self.get_leases_page(
275 | client,
276 | q,
277 | page,
278 | per_page=get_paginate_count(request),
279 | )
280 | leases += page_leases
281 | else:
282 | leases = self.get_leases(client, q, by)
283 |
284 | table = self.get_table(leases, request)
285 | return export_table(
286 | table, "leases.csv", use_selected_columns=request.GET["export"] == "table"
287 | )
288 |
289 | def get(self, request: HttpRequest, **kwargs) -> HttpResponse:
290 | logger = logging.getLogger("netbox_kea.views.BaseServerDHCPLeasesView")
291 |
292 | instance: Server = self.get_object(**kwargs)
293 |
294 | if resp := check_dhcp_enabled(instance, self.dhcp_version):
295 | return resp
296 |
297 | if "export" in request.GET:
298 | return self.get_export(request, **kwargs)
299 |
300 | if not request.htmx:
301 | return super().get(request, **kwargs)
302 |
303 | try:
304 | form = self.form(request.GET)
305 | if not form.is_valid():
306 | table = self.get_table([], request)
307 | return render(
308 | request,
309 | "netbox_kea/server_dhcp_leases_htmx.html",
310 | {
311 | "is_embedded": False,
312 | "form": form,
313 | "table": table,
314 | "paginate": False,
315 | },
316 | )
317 |
318 | by = form.cleaned_data["by"]
319 | q = form.cleaned_data["q"]
320 | client = instance.get_client()
321 | if by == "subnet":
322 | leases, next_page = self.get_leases_page(
323 | client,
324 | q,
325 | form.cleaned_data["page"],
326 | per_page=get_paginate_count(request),
327 | )
328 | paginate = True
329 | else:
330 | paginate = False
331 | next_page = None
332 | leases = self.get_leases(client, q, by)
333 |
334 | table = self.get_table(leases, request)
335 |
336 | can_delete = request.user.has_perm(
337 | "netbox_kea.bulk_delete_lease_from_server",
338 | obj=instance,
339 | )
340 | if not can_delete:
341 | table.columns.hide("pk")
342 |
343 | return render(
344 | request,
345 | "netbox_kea/server_dhcp_leases_htmx.html",
346 | {
347 | "can_delete": can_delete,
348 | "is_embedded": False,
349 | "delete_action": reverse(
350 | f"plugins:netbox_kea:server_leases{self.dhcp_version}_delete",
351 | args=[instance.pk],
352 | ),
353 | "form": form,
354 | "table": table,
355 | "next_page": next_page,
356 | "paginate": paginate,
357 | "page_lengths": EnhancedPaginator.default_page_lengths,
358 | },
359 | )
360 | except Exception as e:
361 | logger.exception("exception on DHCP leases HTMX handler")
362 | return render(
363 | request,
364 | "netbox_kea/exception_htmx.html",
365 | {"type_": type(e).__name__, "exception": str(e)},
366 | )
367 |
368 |
369 | @register_model_view(Server, "leases6")
370 | class ServerLeases6View(BaseServerLeasesView[tables.LeaseTable6]):
371 | tab = OptionalViewTab(
372 | label="DHCPv6 Leases", weight=1010, is_enabled=lambda s: s.dhcp6
373 | )
374 | form = forms.Leases6SearchForm
375 | table = tables.LeaseTable6
376 | dhcp_version = 6
377 |
378 |
379 | @register_model_view(Server, "leases4")
380 | class ServerLeases4View(BaseServerLeasesView[tables.LeaseTable4]):
381 | tab = OptionalViewTab(
382 | label="DHCPv4 Leases", weight=1020, is_enabled=lambda s: s.dhcp4
383 | )
384 | form = forms.Leases4SearchForm
385 | table = tables.LeaseTable4
386 | dhcp_version = 4
387 |
388 |
389 | class FakeLeaseModelMeta:
390 | verbose_name_plural = "leases"
391 |
392 |
393 | # Fake model to allow us to use the bulk_delete.html template.
394 | class FakeLeaseModel:
395 | _meta = FakeLeaseModelMeta
396 |
397 |
398 | class BaseServerLeasesDeleteView(
399 | GetReturnURLMixin, generic.ObjectView, metaclass=ABCMeta
400 | ):
401 | queryset = Server.objects.all()
402 | default_return_url = "plugins:netbox_kea:server_list"
403 |
404 | def delete_lease(self, client: KeaClient, ip: str) -> None:
405 | client.command(
406 | f"lease{self.dhcp_version}-del",
407 | arguments={"ip-address": ip},
408 | service=[f"dhcp{self.dhcp_version}"],
409 | check=(0, 3),
410 | )
411 |
412 | def get(self, request: HttpRequest, **kwargs):
413 | return redirect(self.get_return_url(request, obj=self.get_object(**kwargs)))
414 |
415 | def post(self, request: HttpRequest, **kwargs) -> HttpResponse:
416 | instance: Server = self.get_object(**kwargs)
417 |
418 | if not request.user.has_perm(
419 | "netbox_kea.bulk_delete_lease_from_server", obj=instance
420 | ):
421 | return HttpResponseForbidden(
422 | "This user does not have permission to delete DHCP leases."
423 | )
424 |
425 | form = self.form(request.POST)
426 |
427 | if not form.is_valid():
428 | messages.warning(request, str(form.errors))
429 | return redirect(self.get_return_url(request, obj=instance))
430 |
431 | lease_ips = form.cleaned_data["pk"]
432 | if "_confirm" not in request.POST:
433 | return render(
434 | request,
435 | "generic/bulk_delete.html",
436 | {
437 | "model": FakeLeaseModel,
438 | "table": tables.LeaseDeleteTable(
439 | ({"ip": ip} for ip in lease_ips),
440 | orderable=False,
441 | ),
442 | "form": form,
443 | "return_url": self.get_return_url(request, obj=instance),
444 | },
445 | )
446 |
447 | client = instance.get_client()
448 |
449 | for ip in lease_ips:
450 | try:
451 | self.delete_lease(client, ip)
452 | except Exception as e: # noqa: PERF203
453 | messages.error(request, f"Error deleting lease {ip}: {repr(e)}")
454 | return redirect(self.get_return_url(request, obj=instance))
455 |
456 | messages.success(
457 | request, f"Deleted {len(lease_ips)} DHCPv{self.dhcp_version} lease(s)."
458 | )
459 | return redirect(self.get_return_url(request, obj=instance))
460 |
461 |
462 | class ServerLeases6DeleteView(BaseServerLeasesDeleteView):
463 | form = forms.Lease6DeleteForm
464 | dhcp_version = 6
465 |
466 |
467 | class ServerLeases4DeleteView(BaseServerLeasesDeleteView):
468 | form = forms.Lease4DeleteForm
469 | dhcp_version = 4
470 |
471 |
472 | class BaseServerDHCPSubnetsView(generic.ObjectChildrenView):
473 | table = tables.SubnetTable
474 | queryset = Server.objects.all()
475 | template_name = "netbox_kea/server_dhcp_subnets.html"
476 |
477 | def get_children(
478 | self, request: HttpRequest, parent: Server
479 | ) -> list[dict[str, Any]]:
480 | return self.get_subnets(parent)
481 |
482 | def get_subnets(self, server: Server) -> list[dict[str, Any]]:
483 | client = server.get_client()
484 | config = client.command("config-get", service=[f"dhcp{self.dhcp_version}"])
485 | assert config[0]["arguments"] is not None
486 | subnets = config[0]["arguments"][f"Dhcp{self.dhcp_version}"][
487 | f"subnet{self.dhcp_version}"
488 | ]
489 | subnet_list = [
490 | {
491 | "id": s["id"],
492 | "subnet": s["subnet"],
493 | "dhcp_version": self.dhcp_version,
494 | "server_pk": server.pk,
495 | }
496 | for s in subnets
497 | if "id" in s and "subnet" in s
498 | ]
499 |
500 | for sn in config[0]["arguments"][f"Dhcp{self.dhcp_version}"]["shared-networks"]:
501 | subnet_list.extend(
502 | {
503 | "id": s["id"],
504 | "subnet": s["subnet"],
505 | "shared_network": sn["name"],
506 | "dhcp_version": self.dhcp_version,
507 | "server_pk": server.pk,
508 | }
509 | for s in sn[f"subnet{self.dhcp_version}"]
510 | )
511 |
512 | return subnet_list
513 |
514 | def get(self, request: HttpRequest, **kwargs: Any) -> HttpResponse:
515 | instance = self.get_object(**kwargs)
516 | if resp := check_dhcp_enabled(instance, self.dhcp_version):
517 | return resp
518 |
519 | # We can't use the original get() since it calls get_table_configs which requires a NetBox model.
520 | instance = self.get_object(**kwargs)
521 | child_objects = self.get_children(request, instance)
522 |
523 | table_data = self.prep_table_data(request, child_objects, instance)
524 | table = self.get_table(table_data, request, False)
525 |
526 | if "export" in request.GET:
527 | return export_table(
528 | table,
529 | filename=f"kea-dhcpv{self.dhcp_version}-subnets.csv",
530 | use_selected_columns=request.GET["export"] == "table",
531 | )
532 |
533 | # If this is an HTMX request, return only the rendered table HTML
534 | if htmx_partial(request):
535 | return render(
536 | request,
537 | "htmx/table.html",
538 | {
539 | "object": instance,
540 | "table": table,
541 | "model": self.child_model,
542 | },
543 | )
544 |
545 | return render(
546 | request,
547 | self.get_template_name(),
548 | {
549 | "object": instance,
550 | "base_template": f"{instance._meta.app_label}/{instance._meta.model_name}.html",
551 | "table": table,
552 | "table_config": f"{table.name}_config",
553 | "return_url": request.get_full_path(),
554 | },
555 | )
556 |
557 |
558 | @register_model_view(Server, "subnets6")
559 | class ServerDHCP6SubnetsView(BaseServerDHCPSubnetsView):
560 | tab = OptionalViewTab(
561 | label="DHCPv6 Subnets", weight=1030, is_enabled=lambda s: s.dhcp6
562 | )
563 | dhcp_version = 6
564 |
565 |
566 | @register_model_view(Server, "subnets4")
567 | class ServerDHCP4SubnetsView(BaseServerDHCPSubnetsView):
568 | tab = OptionalViewTab(
569 | label="DHCPv4 Subnets", weight=1040, is_enabled=lambda s: s.dhcp4
570 | )
571 | dhcp_version = 4
572 |
--------------------------------------------------------------------------------
/tests/docker/kea_configs/kea-dhcp4.conf:
--------------------------------------------------------------------------------
1 | // This is a basic configuration for the Kea DHCPv4 server. Subnet declarations
2 | // are mostly commented out and no interfaces are listed. Therefore, the servers
3 | // will not listen or respond to any queries.
4 | // The basic configuration must be extended to specify interfaces on which
5 | // the servers should listen. There are a number of example options defined.
6 | // These probably don't make any sense in your network. Make sure you at least
7 | // update the following, before running this example in your network:
8 | // - change the network interface names
9 | // - change the subnets to match your actual network
10 | // - change the option values to match your network
11 | //
12 | // This is just a very basic configuration. Kea comes with large suite (over 30)
13 | // of configuration examples and extensive Kea User's Guide. Please refer to
14 | // those materials to get better understanding of what this software is able to
15 | // do. Comments in this configuration file sometimes refer to sections for more
16 | // details. These are section numbers in Kea User's Guide. The version matching
17 | // your software should come with your Kea package, but it is also available
18 | // in ISC's Knowledgebase (https://kea.readthedocs.io; the direct link for
19 | // the stable version is https://kea.readthedocs.io/).
20 | //
21 | // This configuration file contains only DHCPv4 server's configuration.
22 | // If configurations for other Kea services are also included in this file they
23 | // are ignored by the DHCPv4 server.
24 | {
25 |
26 | // DHCPv4 configuration starts here. This section will be read by DHCPv4 server
27 | // and will be ignored by other components.
28 | "Dhcp4": {
29 | // Add names of your network interfaces to listen on.
30 | "interfaces-config": {
31 | // See section 8.2.4 for more details. You probably want to add just
32 | // interface name (e.g. "eth0" or specific IPv4 address on that
33 | // interface name (e.g. "eth0/192.0.2.1").
34 | "interfaces": [ ]
35 |
36 | // Kea DHCPv4 server by default listens using raw sockets. This ensures
37 | // all packets, including those sent by directly connected clients
38 | // that don't have IPv4 address yet, are received. However, if your
39 | // traffic is always relayed, it is often better to use regular
40 | // UDP sockets. If you want to do that, uncomment this line:
41 | // "dhcp-socket-type": "udp"
42 | },
43 |
44 | // Kea supports control channel, which is a way to receive management
45 | // commands while the server is running. This is a Unix domain socket that
46 | // receives commands formatted in JSON, e.g. config-set (which sets new
47 | // configuration), config-reload (which tells Kea to reload its
48 | // configuration from file), statistic-get (to retrieve statistics) and many
49 | // more. For detailed description, see Sections 8.8, 16 and 15.
50 | "control-socket": {
51 | "socket-type": "unix",
52 | "socket-name": "/run/kea/kea-dhcp4-ctrl.sock"
53 | },
54 |
55 | // Use Memfile lease database backend to store leases in a CSV file.
56 | // Depending on how Kea was compiled, it may also support SQL databases
57 | // (MySQL and/or PostgreSQL). Those database backends require more
58 | // parameters, like name, host and possibly user and password.
59 | // There are dedicated examples for each backend. See Section 7.2.2 "Lease
60 | // Storage" for details.
61 | "lease-database": {
62 | // Memfile is the simplest and easiest backend to use. It's an in-memory
63 | // C++ database that stores its state in CSV file.
64 | "type": "memfile",
65 | "lfc-interval": 3600
66 | },
67 |
68 | // Kea allows storing host reservations in a database. If your network is
69 | // small or you have few reservations, it's probably easier to keep them
70 | // in the configuration file. If your network is large, it's usually better
71 | // to use database for it. To enable it, uncomment the following:
72 | // "hosts-database": {
73 | // "type": "mysql",
74 | // "name": "kea",
75 | // "user": "kea",
76 | // "password": "kea",
77 | // "host": "localhost",
78 | // "port": 3306
79 | // },
80 | // See Section 7.2.3 "Hosts storage" for details.
81 |
82 | // Setup reclamation of the expired leases and leases affinity.
83 | // Expired leases will be reclaimed every 10 seconds. Every 25
84 | // seconds reclaimed leases, which have expired more than 3600
85 | // seconds ago, will be removed. The limits for leases reclamation
86 | // are 100 leases or 250 ms for a single cycle. A warning message
87 | // will be logged if there are still expired leases in the
88 | // database after 5 consecutive reclamation cycles.
89 | "expired-leases-processing": {
90 | "reclaim-timer-wait-time": 10,
91 | "flush-reclaimed-timer-wait-time": 25,
92 | "hold-reclaimed-time": 3600,
93 | "max-reclaim-leases": 100,
94 | "max-reclaim-time": 250,
95 | "unwarned-reclaim-cycles": 5
96 | },
97 |
98 | // Global timers specified here apply to all subnets, unless there are
99 | // subnet specific values defined in particular subnets.
100 | "renew-timer": 900,
101 | "rebind-timer": 1800,
102 | "valid-lifetime": 3600,
103 |
104 | // Many additional parameters can be specified here:
105 | // - option definitions (if you want to define vendor options, your own
106 | // custom options or perhaps handle standard options
107 | // that Kea does not support out of the box yet)
108 | // - client classes
109 | // - hooks
110 | // - ddns information (how the DHCPv4 component can reach a DDNS daemon)
111 | //
112 | // Some of them have examples below, but there are other parameters.
113 | // Consult Kea User's Guide to find out about them.
114 |
115 | // These are global options. They are going to be sent when a client
116 | // requests them, unless overwritten with values in more specific scopes.
117 | // The scope hierarchy is:
118 | // - global (most generic, can be overwritten by class, subnet or host)
119 | // - class (can be overwritten by subnet or host)
120 | // - subnet (can be overwritten by host)
121 | // - host (most specific, overwrites any other scopes)
122 | //
123 | // Not all of those options make sense. Please configure only those that
124 | // are actually useful in your network.
125 | //
126 | // For a complete list of options currently supported by Kea, see
127 | // Section 7.2.8 "Standard DHCPv4 Options". Kea also supports
128 | // vendor options (see Section 7.2.10) and allows users to define their
129 | // own custom options (see Section 7.2.9).
130 | "option-data": [
131 | // When specifying options, you typically need to specify
132 | // one of (name or code) and data. The full option specification
133 | // covers name, code, space, csv-format and data.
134 | // space defaults to "dhcp4" which is usually correct, unless you
135 | // use encapsulate options. csv-format defaults to "true", so
136 | // this is also correct, unless you want to specify the whole
137 | // option value as long hex string. For example, to specify
138 | // domain-name-servers you could do this:
139 | // {
140 | // "name": "domain-name-servers",
141 | // "code": 6,
142 | // "csv-format": "true",
143 | // "space": "dhcp4",
144 | // "data": "192.0.2.1, 192.0.2.2"
145 | // }
146 | // but it's a lot of writing, so it's easier to do this instead:
147 | {
148 | "name": "domain-name-servers",
149 | "data": "192.0.2.1, 192.0.2.2"
150 | },
151 |
152 | // Typically people prefer to refer to options by their names, so they
153 | // don't need to remember the code names. However, some people like
154 | // to use numerical values. For example, option "domain-name" uses
155 | // option code 15, so you can reference to it either by
156 | // "name": "domain-name" or "code": 15.
157 | {
158 | "code": 15,
159 | "data": "example.org"
160 | },
161 |
162 | // Domain search is also a popular option. It tells the client to
163 | // attempt to resolve names within those specified domains. For
164 | // example, name "foo" would be attempted to be resolved as
165 | // foo.mydomain.example.com and if it fails, then as foo.example.com
166 | {
167 | "name": "domain-search",
168 | "data": "mydomain.example.com, example.com"
169 | },
170 |
171 | // String options that have a comma in their values need to have
172 | // it escaped (i.e. each comma is preceded by two backslashes).
173 | // That's because commas are reserved for separating fields in
174 | // compound options. At the same time, we need to be conformant
175 | // with JSON spec, that does not allow "\,". Therefore the
176 | // slightly uncommon double backslashes notation is needed.
177 |
178 | // Legal JSON escapes are \ followed by "\/bfnrt character
179 | // or \u followed by 4 hexadecimal numbers (currently Kea
180 | // supports only \u0000 to \u00ff code points).
181 | // CSV processing translates '\\' into '\' and '\,' into ','
182 | // only so for instance '\x' is translated into '\x'. But
183 | // as it works on a JSON string value each of these '\'
184 | // characters must be doubled on JSON input.
185 | {
186 | "name": "boot-file-name",
187 | "data": "EST5EDT4\\,M3.2.0/02:00\\,M11.1.0/02:00"
188 | },
189 |
190 | // Options that take integer values can either be specified in
191 | // dec or hex format. Hex format could be either plain (e.g. abcd)
192 | // or prefixed with 0x (e.g. 0xabcd).
193 | {
194 | "name": "default-ip-ttl",
195 | "data": "0xf0"
196 | }
197 |
198 | // Note that Kea provides some of the options on its own. In particular,
199 | // it sends IP Address lease type (code 51, based on valid-lifetime
200 | // parameter, Subnet mask (code 1, based on subnet definition), Renewal
201 | // time (code 58, based on renew-timer parameter), Rebind time (code 59,
202 | // based on rebind-timer parameter).
203 | ],
204 |
205 | // Other global parameters that can be defined here are option definitions
206 | // (this is useful if you want to use vendor options, your own custom
207 | // options or perhaps handle options that Kea does not handle out of the box
208 | // yet).
209 |
210 | // You can also define classes. If classes are defined, incoming packets
211 | // may be assigned to specific classes. A client class can represent any
212 | // group of devices that share some common characteristic, e.g. Windows
213 | // devices, iphones, broken printers that require special options, etc.
214 | // Based on the class information, you can then allow or reject clients
215 | // to use certain subnets, add special options for them or change values
216 | // of some fixed fields.
217 | "client-classes": [
218 | {
219 | // This specifies a name of this class. It's useful if you need to
220 | // reference this class.
221 | "name": "voip",
222 |
223 | // This is a test. It is an expression that is being evaluated on
224 | // each incoming packet. It is supposed to evaluate to either
225 | // true or false. If it's true, the packet is added to specified
226 | // class. See Section 12 for a list of available expressions. There
227 | // are several dozens. Section 8.2.14 for more details for DHCPv4
228 | // classification and Section 9.2.19 for DHCPv6.
229 | "test": "substring(option[60].hex,0,6) == 'Aastra'",
230 |
231 | // If a client belongs to this class, you can define extra behavior.
232 | // For example, certain fields in DHCPv4 packet will be set to
233 | // certain values.
234 | "next-server": "192.0.2.254",
235 | "server-hostname": "hal9000",
236 | "boot-file-name": "/dev/null"
237 |
238 | // You can also define option values here if you want devices from
239 | // this class to receive special options.
240 | }
241 | ],
242 |
243 | // Another thing possible here are hooks. Kea supports a powerful mechanism
244 | // that allows loading external libraries that can extract information and
245 | // even influence how the server processes packets. Those libraries include
246 | // additional forensic logging capabilities, ability to reserve hosts in
247 | // more flexible ways, and even add extra commands. For a list of available
248 | // hook libraries, see https://gitlab.isc.org/isc-projects/kea/wikis/Hooks-available.
249 | "hooks-libraries": [
250 | {"library": "/usr/lib/kea/hooks/libdhcp_lease_cmds.so"},
251 | {"library": "/usr/lib/kea/hooks/libdhcp_stat_cmds.so"}
252 | ],
253 | // {
254 | // // Forensic Logging library generates forensic type of audit trail
255 | // // of all devices serviced by Kea, including their identifiers
256 | // // (like MAC address), their location in the network, times
257 | // // when they were active etc.
258 | // "library": "/usr/lib/kea/hooks/libdhcp_legal_log.so",
259 | // "parameters": {
260 | // "path": "/var/lib/kea",
261 | // "base-name": "kea-forensic4"
262 | // }
263 | // },
264 | // {
265 | // // Flexible identifier (flex-id). Kea software provides a way to
266 | // // handle host reservations that include addresses, prefixes,
267 | // // options, client classes and other features. The reservation can
268 | // // be based on hardware address, DUID, circuit-id or client-id in
269 | // // DHCPv4 and using hardware address or DUID in DHCPv6. However,
270 | // // there are sometimes scenario where the reservation is more
271 | // // complex, e.g. uses other options that mentioned above, uses part
272 | // // of specific options or perhaps even a combination of several
273 | // // options and fields to uniquely identify a client. Those scenarios
274 | // // are addressed by the Flexible Identifiers hook application.
275 | // "library": "/usr/lib/kea/hooks/libdhcp_flex_id.so",
276 | // "parameters": {
277 | // "identifier-expression": "relay4[2].hex"
278 | // }
279 | // }
280 | // ],
281 |
282 | // Below an example of a simple IPv4 subnet declaration. Uncomment to enable
283 | // it. This is a list, denoted with [ ], of structures, each denoted with
284 | // { }. Each structure describes a single subnet and may have several
285 | // parameters. One of those parameters is "pools" that is also a list of
286 | // structures.
287 | "subnet4": [
288 | {
289 | // This defines the whole subnet. Kea will use this information to
290 | // determine where the clients are connected. This is the whole
291 | // subnet in your network. This is mandatory parameter for each
292 | // subnet.
293 | "subnet": "192.0.2.0/24",
294 | "id": 1,
295 |
296 | // Pools define the actual part of your subnet that is governed
297 | // by Kea. Technically this is optional parameter, but it's
298 | // almost always needed for DHCP to do its job. If you omit it,
299 | // clients won't be able to get addresses, unless there are
300 | // host reservations defined for them.
301 | "pools": [ { "pool": "192.0.2.1 - 192.0.2.200" } ],
302 |
303 | // These are options that are subnet specific. In most cases,
304 | // you need to define at least routers option, as without this
305 | // option your clients will not be able to reach their default
306 | // gateway and will not have Internet connectivity.
307 | "option-data": [
308 | {
309 | // For each IPv4 subnet you most likely need to specify at
310 | // least one router.
311 | "name": "routers",
312 | "data": "192.0.2.1"
313 | }
314 | ],
315 |
316 | // Kea offers host reservations mechanism. Kea supports reservations
317 | // by several different types of identifiers: hw-address
318 | // (hardware/MAC address of the client), duid (DUID inserted by the
319 | // client), client-id (client identifier inserted by the client) and
320 | // circuit-id (circuit identifier inserted by the relay agent).
321 | //
322 | // Kea also support flexible identifier (flex-id), which lets you
323 | // specify an expression that is evaluated for each incoming packet.
324 | // Resulting value is then used for as an identifier.
325 | //
326 | // Note that reservations are subnet-specific in Kea. This is
327 | // different than ISC DHCP. Keep that in mind when migrating
328 | // your configurations.
329 | "reservations": [
330 |
331 | // This is a reservation for a specific hardware/MAC address.
332 | // It's a rather simple reservation: just an address and nothing
333 | // else.
334 | {
335 | "hw-address": "1a:1b:1c:1d:1e:1f",
336 | "ip-address": "192.0.2.201"
337 | },
338 |
339 | // This is a reservation for a specific client-id. It also shows
340 | // the this client will get a reserved hostname. A hostname can
341 | // be defined for any identifier type, not just client-id.
342 | {
343 | "client-id": "01:11:22:33:44:55:66",
344 | "ip-address": "192.0.2.202",
345 | "hostname": "special-snowflake"
346 | },
347 |
348 | // The third reservation is based on DUID. This reservation defines
349 | // a special option values for this particular client. If the
350 | // domain-name-servers option would have been defined on a global,
351 | // subnet or class level, the host specific values take preference.
352 | {
353 | "duid": "01:02:03:04:05",
354 | "ip-address": "192.0.2.203",
355 | "option-data": [ {
356 | "name": "domain-name-servers",
357 | "data": "10.1.1.202, 10.1.1.203"
358 | } ]
359 | },
360 |
361 | // The fourth reservation is based on circuit-id. This is an option
362 | // inserted by the relay agent that forwards the packet from client
363 | // to the server. In this example the host is also assigned vendor
364 | // specific options.
365 | //
366 | // When using reservations, it is useful to configure
367 | // reservations-global, reservations-in-subnet,
368 | // reservations-out-of-pool (subnet specific parameters)
369 | // and host-reservation-identifiers (global parameter).
370 | {
371 | "client-id": "01:12:23:34:45:56:67",
372 | "ip-address": "192.0.2.204",
373 | "option-data": [
374 | {
375 | "name": "vivso-suboptions",
376 | "data": "4491"
377 | },
378 | {
379 | "name": "tftp-servers",
380 | "space": "vendor-4491",
381 | "data": "10.1.1.202, 10.1.1.203"
382 | }
383 | ]
384 | },
385 | // This reservation is for a client that needs specific DHCPv4
386 | // fields to be set. Three supported fields are next-server,
387 | // server-hostname and boot-file-name
388 | {
389 | "client-id": "01:0a:0b:0c:0d:0e:0f",
390 | "ip-address": "192.0.2.205",
391 | "next-server": "192.0.2.1",
392 | "server-hostname": "hal9000",
393 | "boot-file-name": "/dev/null"
394 | },
395 | // This reservation is using flexible identifier. Instead of
396 | // relying on specific field, sysadmin can define an expression
397 | // similar to what is used for client classification,
398 | // e.g. substring(relay[0].option[17],0,6). Then, based on the
399 | // value of that expression for incoming packet, the reservation
400 | // is matched. Expression can be specified either as hex or
401 | // plain text using single quotes.
402 | //
403 | // Note: flexible identifier requires flex_id hook library to be
404 | // loaded to work.
405 | {
406 | "flex-id": "'s0mEVaLue'",
407 | "ip-address": "192.0.2.206"
408 | }
409 | // You can add more reservations here.
410 | ]
411 | // You can add more subnets there.
412 | }
413 | ],
414 |
415 | "shared-networks": [
416 | {
417 | "name": "test-shared-network-4",
418 | "subnet4": [
419 | {
420 | "id": 2,
421 | "subnet": "198.51.100.0/24",
422 | "pools": [ { "pool": "198.51.100.1 - 198.51.100.200" } ]
423 | }
424 | ]
425 | }
426 | ],
427 |
428 | // There are many, many more parameters that DHCPv4 server is able to use.
429 | // They were not added here to not overwhelm people with too much
430 | // information at once.
431 |
432 | // Logging configuration starts here. Kea uses different loggers to log various
433 | // activities. For details (e.g. names of loggers), see Chapter 18.
434 | "loggers": [
435 | {
436 | // This section affects kea-dhcp4, which is the base logger for DHCPv4
437 | // component. It tells DHCPv4 server to write all log messages (on
438 | // severity INFO or more) to a file.
439 | "name": "kea-dhcp4",
440 | "output_options": [
441 | {
442 | // Specifies the output file. There are several special values
443 | // supported:
444 | // - stdout (prints on standard output)
445 | // - stderr (prints on standard error)
446 | // - syslog (logs to syslog)
447 | // - syslog:name (logs to syslog using specified name)
448 | // Any other value is considered a name of the file
449 | "output": "stdout",
450 |
451 | // Shorter log pattern suitable for use with systemd,
452 | // avoids redundant information
453 | // "pattern": "%-5p %m\n",
454 |
455 | // This governs whether the log output is flushed to disk after
456 | // every write.
457 | // "flush": false,
458 |
459 | // This specifies the maximum size of the file before it is
460 | // rotated.
461 | // "maxsize": 1048576,
462 |
463 | // This specifies the maximum number of rotated files to keep.
464 | // "maxver": 8
465 | }
466 | ],
467 | // This specifies the severity of log messages to keep. Supported values
468 | // are: FATAL, ERROR, WARN, INFO, DEBUG
469 | "severity": "INFO",
470 |
471 | // If DEBUG level is specified, this value is used. 0 is least verbose,
472 | // 99 is most verbose. Be cautious, Kea can generate lots and lots
473 | // of logs if told to do so.
474 | "debuglevel": 0
475 | }
476 | ]
477 | }
478 | }
479 |
--------------------------------------------------------------------------------
/tests/test_ui.py:
--------------------------------------------------------------------------------
1 | import csv
2 | import re
3 | from collections.abc import Sequence
4 | from datetime import datetime, timezone
5 | from typing import Any, Literal
6 |
7 | import pynetbox
8 | import pytest
9 | import requests
10 | from netaddr import EUI, IPAddress, IPNetwork, mac_unix_expanded
11 | from playwright.sync_api import Page, expect
12 |
13 | from . import constants
14 |
15 | # This is linked from netbox_kea to avoid import errors
16 | from .kea import KeaClient
17 |
18 |
19 | @pytest.fixture
20 | def requests_session(nb_api: pynetbox.api) -> requests.Session:
21 | s = requests.Session()
22 | s.headers.update(
23 | {
24 | "Authorization": f"Token {nb_api.token}",
25 | "Content-Type": "application/json",
26 | "Accept": "application/json",
27 | }
28 | )
29 | return s
30 |
31 |
32 | @pytest.fixture(autouse=True)
33 | def clear_leases(kea_client: KeaClient) -> None:
34 | kea_client.command("lease4-wipe", service=["dhcp4"], check=(0, 3))
35 | kea_client.command("lease6-wipe", service=["dhcp6"], check=(0, 3))
36 |
37 |
38 | @pytest.fixture(autouse=True)
39 | def reset_user_preferences(
40 | requests_session: requests.Session, nb_api: pynetbox.api
41 | ) -> None:
42 | r = requests_session.get(url=f"{nb_api.base_url}/users/config/")
43 | r.raise_for_status()
44 | tables_config = r.json().get("tables", {})
45 |
46 | # pynetbox doesn't support this endpoint
47 | requests_session.patch(
48 | url=f"{nb_api.base_url}/users/config/",
49 | json={"tables": {k: {} for k in tables_config}},
50 | ).raise_for_status()
51 |
52 | # restore pagination
53 | requests_session.patch(
54 | url=f"{nb_api.base_url}/users/config/",
55 | json={"pagination": {"placement": "bottom"}},
56 | ).raise_for_status()
57 |
58 |
59 | @pytest.fixture
60 | def with_test_server(
61 | nb_api: pynetbox.api, kea_url: str, page: Page, netbox_login: None, plugin_base: str
62 | ):
63 | server = nb_api.plugins.kea.servers.create(name="test", server_url=kea_url)
64 | page.goto(f"{plugin_base}/servers/{server.id}/")
65 | yield
66 | server.delete()
67 |
68 |
69 | @pytest.fixture
70 | def with_test_server_only6(
71 | nb_api: pynetbox.api, kea_url: str, page: Page, netbox_login: None, plugin_base: str
72 | ):
73 | server = nb_api.plugins.kea.servers.create(
74 | name="only6", server_url=kea_url, dhcp4=False, dhcp6=True
75 | )
76 | page.goto(f"{plugin_base}/servers/{server.id}/")
77 | yield
78 | server.delete()
79 |
80 |
81 | @pytest.fixture
82 | def with_test_server_only4(
83 | nb_api: pynetbox.api, kea_url: str, page: Page, netbox_login: None, plugin_base: str
84 | ):
85 | server = nb_api.plugins.kea.servers.create(
86 | name="only4", server_url=kea_url, dhcp4=True, dhcp6=False
87 | )
88 | page.goto(f"{plugin_base}/servers/{server.id}/")
89 | yield
90 | server.delete()
91 |
92 |
93 | @pytest.fixture
94 | def kea_client() -> KeaClient:
95 | return KeaClient("http://localhost:8001")
96 |
97 |
98 | @pytest.fixture
99 | def kea(with_test_server: None, kea_client: KeaClient) -> KeaClient:
100 | return kea_client
101 |
102 |
103 | @pytest.fixture
104 | def plugin_base(netbox_url: str) -> str:
105 | return f"{netbox_url}/plugins/kea"
106 |
107 |
108 | @pytest.fixture
109 | def lease6(kea: KeaClient) -> dict[str, Any]:
110 | lease_ip = "2001:db8:1::1"
111 | kea.command(
112 | "lease6-add",
113 | service=["dhcp6"],
114 | arguments={
115 | "ip-address": lease_ip,
116 | "duid": "01:02:03:04:05:06:07:08",
117 | "hw-address": "08:08:08:08:08:08",
118 | "iaid": 1,
119 | "valid-lft": 3600,
120 | "hostname": "test-lease6",
121 | "preferred-lft": 7200,
122 | },
123 | )
124 | lease = kea.command(
125 | "lease6-get", arguments={"ip-address": lease_ip}, service=["dhcp6"]
126 | )[0]["arguments"]
127 | assert lease is not None
128 | return lease
129 |
130 |
131 | @pytest.fixture
132 | def lease6_netbox_device(
133 | nb_api: pynetbox.api,
134 | test_device_type: int,
135 | test_device_role: int,
136 | test_site: int,
137 | lease6: dict[str, Any],
138 | ):
139 | version = nb_api.version
140 |
141 | lease_ip = lease6["ip-address"]
142 |
143 | device = nb_api.dcim.devices.create(
144 | name=lease6["hostname"],
145 | device_type=test_device_type,
146 | site=test_site,
147 | role=test_device_role,
148 | )
149 |
150 | interface = nb_api.dcim.interfaces.create(
151 | name="eth0",
152 | type="1000base-t",
153 | device=device.id,
154 | mac_address=lease6["hw-address"],
155 | )
156 |
157 | if version not in ("4.0", "4.1"):
158 | intf_mac = nb_api.dcim.mac_addresses.create(
159 | mac_address=lease6["hw-address"],
160 | assigned_object_type="dcim.interface",
161 | assigned_object_id=interface.id,
162 | )
163 | assert interface.update({"primary_mac_address": intf_mac.id})
164 |
165 | ip = nb_api.ipam.ip_addresses.create(
166 | address=f"{lease_ip}/64",
167 | assigned_object_type="dcim.interface",
168 | assigned_object_id=interface.id,
169 | )
170 |
171 | yield lease_ip
172 | ip.delete()
173 | interface.delete()
174 | device.delete()
175 |
176 |
177 | @pytest.fixture
178 | def lease6_netbox_vm(
179 | nb_api: pynetbox.api,
180 | test_cluster: int,
181 | test_device_role: int,
182 | lease6: dict[str, Any],
183 | ):
184 | version = nb_api.version
185 |
186 | lease_ip = lease6["ip-address"]
187 |
188 | vm = nb_api.virtualization.virtual_machines.create(
189 | name=lease6["hostname"],
190 | cluster=test_cluster,
191 | role=test_device_role,
192 | )
193 | interface = nb_api.virtualization.interfaces.create(
194 | name="eth0", virtual_machine=vm.id, mac_address=lease6["hw-address"]
195 | )
196 | if version not in ("4.0", "4.1"):
197 | intf_mac = nb_api.dcim.mac_addresses.create(
198 | mac_address=lease6["hw-address"],
199 | assigned_object_type="virtualization.vminterface",
200 | assigned_object_id=interface.id,
201 | )
202 | assert interface.update({"primary_mac_address": intf_mac.id})
203 |
204 | ip = nb_api.ipam.ip_addresses.create(
205 | address=f"{lease_ip}/64",
206 | assigned_object_type="virtualization.vminterface",
207 | assigned_object_id=interface.id,
208 | )
209 |
210 | yield lease_ip
211 |
212 | ip.delete()
213 | interface.delete()
214 | vm.delete()
215 |
216 |
217 | @pytest.fixture
218 | def lease6_netbox_ip(nb_api: pynetbox.api, lease6: dict[str, Any]):
219 | lease_ip = lease6["ip-address"]
220 | ip = nb_api.ipam.ip_addresses.create(address=f"{lease_ip}/64")
221 | yield lease_ip
222 | ip.delete()
223 |
224 |
225 | @pytest.fixture
226 | def lease4(kea: KeaClient) -> dict[str, Any]:
227 | lease_ip = "192.0.2.1"
228 | kea.command(
229 | "lease4-add",
230 | service=["dhcp4"],
231 | arguments={
232 | "ip-address": lease_ip,
233 | "hw-address": "08:08:08:08:08:08",
234 | "client-id": "18:08:08:08:08:08",
235 | "hostname": "test-lease4",
236 | },
237 | )
238 | lease = kea.command(
239 | "lease4-get", arguments={"ip-address": lease_ip}, service=["dhcp4"]
240 | )[0]["arguments"]
241 | assert lease is not None
242 | return lease
243 |
244 |
245 | @pytest.fixture
246 | def lease4_netbox_device(
247 | nb_api: pynetbox.api,
248 | test_device_type: int,
249 | test_device_role: int,
250 | test_site: int,
251 | lease4: dict[str, Any],
252 | ):
253 | version = nb_api.version
254 | device_role_key = "role"
255 |
256 | lease_ip = lease4["ip-address"]
257 |
258 | device = nb_api.dcim.devices.create(
259 | name=lease4["hostname"],
260 | device_type=test_device_type,
261 | site=test_site,
262 | **{device_role_key: test_device_role},
263 | )
264 |
265 | interface = nb_api.dcim.interfaces.create(
266 | name="eth0",
267 | type="1000base-t",
268 | device=device.id,
269 | mac_address=lease4["hw-address"],
270 | )
271 |
272 | if version not in ("4.0", "4.1"):
273 | intf_mac = nb_api.dcim.mac_addresses.create(
274 | mac_address=lease4["hw-address"],
275 | assigned_object_type="dcim.interface",
276 | assigned_object_id=interface.id,
277 | )
278 | assert interface.update({"primary_mac_address": intf_mac.id})
279 |
280 | ip = nb_api.ipam.ip_addresses.create(
281 | address=f"{lease_ip}/24",
282 | assigned_object_type="dcim.interface",
283 | assigned_object_id=interface.id,
284 | )
285 |
286 | yield lease_ip
287 | ip.delete()
288 | interface.delete()
289 | device.delete()
290 |
291 |
292 | @pytest.fixture
293 | def lease4_netbox_vm(
294 | nb_api: pynetbox.api,
295 | test_cluster: int,
296 | test_device_role: int,
297 | lease4: dict[str, Any],
298 | ):
299 | version = nb_api.version
300 |
301 | lease_ip = lease4["ip-address"]
302 |
303 | vm = nb_api.virtualization.virtual_machines.create(
304 | name=lease4["hostname"],
305 | cluster=test_cluster,
306 | role=test_device_role,
307 | )
308 |
309 | interface = nb_api.virtualization.interfaces.create(
310 | name="eth0", virtual_machine=vm.id, mac_address=lease4["hw-address"]
311 | )
312 |
313 | if version not in ("4.0", "4.1"):
314 | intf_mac = nb_api.dcim.mac_addresses.create(
315 | mac_address=lease4["hw-address"],
316 | assigned_object_type="virtualization.vminterface",
317 | assigned_object_id=interface.id,
318 | )
319 | assert interface.update({"primary_mac_address": intf_mac.id})
320 |
321 | ip = nb_api.ipam.ip_addresses.create(
322 | address=f"{lease_ip}/24",
323 | assigned_object_type="virtualization.vminterface",
324 | assigned_object_id=interface.id,
325 | )
326 |
327 | yield lease_ip
328 |
329 | ip.delete()
330 | interface.delete()
331 | vm.delete()
332 |
333 |
334 | @pytest.fixture
335 | def lease4_netbox_ip(nb_api: pynetbox.api, lease4: dict[str, Any]):
336 | lease_ip = lease4["ip-address"]
337 | ip = nb_api.ipam.ip_addresses.create(address=f"{lease_ip}/24")
338 | yield lease_ip
339 | ip.delete()
340 |
341 |
342 | @pytest.fixture
343 | def leases6_250(kea: KeaClient) -> None:
344 | for i in range(1, 251):
345 | kea.command(
346 | "lease6-add",
347 | service=["dhcp6"],
348 | arguments={
349 | "ip-address": f"2001:db8:1::{i:x}",
350 | "duid": str(EUI(i * 10, dialect=mac_unix_expanded)),
351 | "hw-address": str(EUI(i, dialect=mac_unix_expanded)),
352 | "iaid": i,
353 | "valid-lft": 3600,
354 | "hostname": f"test-lease6-{i}",
355 | "preferred-lft": 7200,
356 | },
357 | )
358 |
359 |
360 | @pytest.fixture
361 | def leases4_250(kea: KeaClient) -> None:
362 | for i in range(1, 251):
363 | kea.command(
364 | "lease4-add",
365 | service=["dhcp4"],
366 | arguments={
367 | "ip-address": f"192.0.2.{i}",
368 | "client-id": str(EUI(i * 10, dialect=mac_unix_expanded)),
369 | "hw-address": str(EUI(i, dialect=mac_unix_expanded)),
370 | "hostname": f"test-lease4-{i}",
371 | },
372 | )
373 |
374 |
375 | @pytest.fixture(scope="function")
376 | def netbox_user_permissions() -> list[dict[str, list[Any]]]:
377 | return [{"actions": [], "object_types": []}]
378 |
379 |
380 | @pytest.fixture(scope="function", autouse=True)
381 | def netbox_login(
382 | page: Page,
383 | netbox_url: str,
384 | netbox_username: str,
385 | netbox_password: str,
386 | netbox_user_permissions: list[dict[str, list[Any]]],
387 | nb_api: pynetbox.api,
388 | ):
389 | to_delete = []
390 | if netbox_username != "admin":
391 | nb_api.users.users.filter(username=netbox_username).delete()
392 | nb_api.users.permissions.all(0).delete()
393 | user = nb_api.users.users.create(
394 | username=netbox_username, password=netbox_password
395 | )
396 | to_delete.append(user)
397 | for permission in netbox_user_permissions:
398 | p = nb_api.users.permissions.create(
399 | name=netbox_username,
400 | actions=permission["actions"],
401 | object_types=permission["object_types"],
402 | users=[user.id],
403 | )
404 | to_delete.append(p)
405 |
406 | page.goto(f"{netbox_url}/login/")
407 | page.get_by_label("Username").fill(netbox_username)
408 | page.get_by_label("Password").fill(netbox_password)
409 | page.get_by_role("button", name="Sign In").click()
410 |
411 | yield
412 |
413 | for obj in to_delete:
414 | assert obj.delete()
415 |
416 |
417 | @pytest.fixture(scope="session")
418 | def test_tag(nb_api: pynetbox.api):
419 | tag = nb_api.extras.tags.create(name="kea-test", slug="kea-test")
420 | assert tag is not None
421 | yield tag.name
422 | tag.delete()
423 |
424 |
425 | @pytest.fixture(scope="session")
426 | def test_site(nb_api: pynetbox.api):
427 | site = nb_api.dcim.sites.create(name="Test Site", slug="test-site")
428 | yield site.id
429 | site.delete()
430 |
431 |
432 | @pytest.fixture(scope="session")
433 | def test_device_type(nb_api: pynetbox.api):
434 | manufacturer = nb_api.dcim.manufacturers.create(
435 | name="Test Manufacturer", slug="test-manufacturer"
436 | )
437 | device_type = nb_api.dcim.device_types.create(
438 | manufacturer=manufacturer.id,
439 | model="test model",
440 | slug="test-model",
441 | )
442 | yield device_type.id
443 | device_type.delete()
444 | manufacturer.delete()
445 |
446 |
447 | @pytest.fixture(scope="session")
448 | def test_device_role(nb_api: pynetbox.api):
449 | role = nb_api.dcim.device_roles.create(name="Test Role", slug="test-role")
450 | yield role.id
451 | role.delete()
452 |
453 |
454 | @pytest.fixture(scope="session")
455 | def test_cluster(nb_api: pynetbox.api):
456 | cluster_type = nb_api.virtualization.cluster_types.create(
457 | name="test cluster type",
458 | slug="test-cluster-type",
459 | )
460 | cluster = nb_api.virtualization.clusters.create(
461 | name="Test Cluster", type=cluster_type.id
462 | )
463 | yield cluster.id
464 | cluster.delete()
465 | cluster_type.delete()
466 |
467 |
468 | def search_lease(page: Page, version: Literal[4, 6], by: str, q: str) -> None:
469 | page.get_by_role("link", name=f"DHCPv{version} Leases").click()
470 | page.locator("#id_q").fill(q)
471 | page.locator("#id_by + div.form-select").click()
472 | page.locator("#id_by-ts-dropdown").get_by_role(
473 | "option", name=by, exact=True
474 | ).click()
475 | with page.expect_response(re.compile(f"/leases{version}/")) as r:
476 | page.get_by_role("button", name="Search").click()
477 | assert r.value.ok
478 |
479 |
480 | def search_lease_related(page: Page, model: str) -> None:
481 | page.locator("span.dropdown > a.btn-secondary").click()
482 | page.get_by_role("link", name=f"Search {model}").click()
483 | expect(page.get_by_text("Showing 1-1 of 1")).to_have_count(1)
484 |
485 |
486 | def expect_form_error_search(page: Page, b: bool) -> None:
487 | expect(page.locator("#id_q + div.form-text.text-danger")).to_have_count(int(b))
488 |
489 |
490 | def _version_ge_43(page: Page) -> bool:
491 | """
492 | Return True if the version is >= 4.3.
493 | """
494 |
495 | old_version_strings = (
496 | "(v4.0.11)",
497 | '