Merge charm-vpn-strongswan:strongswan-dev into charm-vpn-strongswan:master
- Git
- lp:charm-vpn-strongswan
- strongswan-dev
- Merge into master
Status: | Work in progress |
---|---|
Proposed branch: | charm-vpn-strongswan:strongswan-dev |
Merge into: | charm-vpn-strongswan:master |
Diff against target: |
2441 lines (+2269/-0) 28 files modified
.gitignore (+42/-0) .gitmodules (+4/-0) Makefile (+81/-0) README.md (+22/-0) config.yaml (+58/-0) copyright (+16/-0) hooks/.empty (+0/-0) icon.svg (+309/-0) lib/__init__.py (+1/-0) lib/lib_strongswan.py (+426/-0) lib/strongswan_constants.py (+49/-0) lib/vpncommon (+1/-0) metadata.yaml (+22/-0) mod/vpncommon (+1/-0) requirements.txt (+4/-0) src/charm.py (+168/-0) templates/strongswan.conf.j2 (+27/-0) templates/swanctl.conf.j2 (+57/-0) tests/functional/requirements.txt (+2/-0) tests/functional/tests/bundles/bionic.yaml (+11/-0) tests/functional/tests/bundles/focal.yaml (+11/-0) tests/functional/tests/test_vpn_strongswan.py (+239/-0) tests/functional/tests/tests.yaml (+11/-0) tests/functional/tests/utils.py (+182/-0) tests/unit/requirements.txt (+7/-0) tests/unit/test_charm.py (+179/-0) tests/unit/test_lib_strongswan.py (+260/-0) tox.ini (+79/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Diko Parvanov | Approve | ||
BootStack Reviewers | mr tracking; do not claim | Pending | |
BootStack Reviewers | Pending | ||
Review via email: mp+389952@code.launchpad.net |
Commit message
Description of the change
Diko Parvanov (dparv) wrote : | # |
Reviewed code, worked on deployments and testing with values. Seems to me like a minimum viable product that creates a ipsec tunnel and runs firewall rules with the configurations options. What needs to be worked on is unit and functional testing for a complete product.
Paul Goins (vultaire) wrote : | # |
Old MR, marked as approved but not actually merged.
Do we still desire/need this?
If so, perhaps we need to re-review, considering how long this has been?
I've moved the overall state of the MR from "Approved" to "Needs review", and added to the bootstack reviewers queue.
Eric Chen (eric-chen) wrote : | # |
No resource to handover it and review it. Mark it WIP as temp solution
Unmerged commits
- d8dbf2b... by Alvaro Uria
-
Run black on code
- 2edc2fb... by Zachary Zehring
-
Add to docstring reasoning for aa complain being set.
- 68a1af4... by Zachary Zehring
-
Fix lint issues.
- refactored enable and restart calls in config_changed to reduce
complexity
- fix imports
- add test case docstrings
- fix line lengths - b1b76c1... by Zachary Zehring
-
Add docstrings for exceptions.
- 0856f44... by Zachary Zehring
-
Fix lint issues.
- fix import orderings
- cleanup start-of-doc documentation
- adjust lines for line length - 5232a90... by Zachary Zehring
-
Adjust import format for import list.
- 128624c... by Zachary Zehring
-
Add distro dependency.
- 6260aa3... by Zachary Zehring
-
Change subprocess.calls to check_outputs. Add error handling.
This commit fixes subprocess calls to allow for error handling
when subprocess commands fail. This also includes error handling
in the charm.py level for setting BlockedStatus and whatnot.
Heavy work was also done in allowing compatibility for focal and
bionic.NOTE: currently, app armor profile for strongSwan related services
are all set to complain to allow for full functionality but still
get some audit of what needs configuring. - ea3ccd3... by Alvaro Uria
-
Fix StrongSwanManager validation
- 542d8a7... by Alvaro Uria
-
Fix lib_strongswan.
generate_ debug_from_ level
Preview Diff
1 | diff --git a/.gitignore b/.gitignore |
2 | new file mode 100644 |
3 | index 0000000..2a94471 |
4 | --- /dev/null |
5 | +++ b/.gitignore |
6 | @@ -0,0 +1,42 @@ |
7 | +# Juju files |
8 | +.unit-state.db |
9 | +.go-cookies |
10 | + |
11 | +layers/* |
12 | +interfaces/* |
13 | + |
14 | +# Byte-compiled / optimized / DLL files |
15 | +__pycache__/ |
16 | +*.py[cod] |
17 | +*$py.class |
18 | + |
19 | +# Tests files and dir |
20 | +.pytest_cache/ |
21 | +.coverage |
22 | +.tox |
23 | +report/ |
24 | +htmlcov/ |
25 | + |
26 | +# Log files |
27 | +*.log |
28 | + |
29 | +# pycharm |
30 | +.idea/ |
31 | + |
32 | +# vi |
33 | +.*.swp |
34 | + |
35 | +# version data |
36 | +repo-info |
37 | +version |
38 | + |
39 | +# Python builds |
40 | +deb_dist/ |
41 | +dist/ |
42 | + |
43 | +# Snaps |
44 | +*.snap |
45 | + |
46 | +# Builds |
47 | +.build/ |
48 | +build |
49 | diff --git a/.gitmodules b/.gitmodules |
50 | new file mode 100644 |
51 | index 0000000..de18452 |
52 | --- /dev/null |
53 | +++ b/.gitmodules |
54 | @@ -0,0 +1,4 @@ |
55 | +[submodule "mod/vpncommon"] |
56 | + path = mod/vpncommon |
57 | + url = https://git.launchpad.net/charm-vpn-common |
58 | + branch = dev/common |
59 | diff --git a/Makefile b/Makefile |
60 | new file mode 100644 |
61 | index 0000000..5c5f603 |
62 | --- /dev/null |
63 | +++ b/Makefile |
64 | @@ -0,0 +1,81 @@ |
65 | +PYTHON := /usr/bin/python3 |
66 | + |
67 | +PROJECTPATH=$(dir $(realpath $(MAKEFILE_LIST))) |
68 | +ifndef CHARM_BUILD_DIR |
69 | + CHARM_BUILD_DIR=${PROJECTPATH}.build |
70 | +endif |
71 | +METADATA_FILE="metadata.yaml" |
72 | +CHARM_NAME=$(shell cat ${PROJECTPATH}/${METADATA_FILE} | grep -E '^name:' | awk '{print $$2}') |
73 | + |
74 | +help: |
75 | + @echo "This project supports the following targets" |
76 | + @echo "" |
77 | + @echo " make help - show this text" |
78 | + @echo " make clean - remove unneeded files" |
79 | + @echo " make submodules - make sure that the submodules are up-to-date" |
80 | + @echo " make submodules-update - update submodules to latest changes on remote branch" |
81 | + @echo " make build - build the charm" |
82 | + @echo " make release - run clean, submodules and build targets" |
83 | + @echo " make lint - run flake8 and black --check" |
84 | + @echo " make black - run black and reformat files" |
85 | + @echo " make proof - run charm proof" |
86 | + @echo " make unittests - run the tests defined in the unittest subdirectory" |
87 | + @echo " make functional - run the tests defined in the functional subdirectory" |
88 | + @echo " make test - run lint, proof, unittests and functional targets" |
89 | + @echo "" |
90 | + |
91 | +clean: |
92 | + @echo "Cleaning files" |
93 | + @git clean -ffXd -e '!.idea' -e '!lib/vpncommon' |
94 | + @echo "Cleaning existing build" |
95 | + @rm -rf ${CHARM_BUILD_DIR}/${CHARM_NAME} |
96 | + |
97 | +submodules: |
98 | + @echo "Cloning submodules" |
99 | + @git submodule update --init --recursive |
100 | + |
101 | +submodules-update: |
102 | + @echo "Pulling latest updates for submodules" |
103 | + @git submodule update --init --recursive --remote --merge |
104 | + |
105 | +build: |
106 | + @echo "Building charm to base directory ${CHARM_BUILD_DIR}/${CHARM_NAME}" |
107 | + @-git rev-parse --abbrev-ref HEAD > ./repo-info |
108 | + @-git describe --always > ./version |
109 | + @mkdir -p ${CHARM_BUILD_DIR}/${CHARM_NAME} |
110 | + @tox -e build |
111 | + @mv ${CHARM_NAME}.charm ${CHARM_BUILD_DIR}/. |
112 | + |
113 | +release: clean build unpack |
114 | + @echo "Charm is built at ${CHARM_BUILD_DIR}/${CHARM_NAME}" |
115 | + |
116 | +unpack: build |
117 | + @mkdir -p ${CHARM_BUILD_DIR}/${CHARM_NAME} |
118 | + @echo "Unpacking built .charm into ${CHARM_BUILD_DIR}/${CHARM_NAME}" |
119 | + @cd ${CHARM_BUILD_DIR}/${CHARM_NAME} && unzip -q ${CHARM_BUILD_DIR}/${CHARM_NAME}.charm |
120 | + |
121 | +lint: |
122 | + @echo "Running lint checks" |
123 | + @tox -e lint |
124 | + |
125 | +black: |
126 | + @echo "Reformat files with black" |
127 | + @tox -e black |
128 | + |
129 | +proof: unpack |
130 | + @echo "Running charm proof" |
131 | + @cd ${CHARM_BUILD_DIR}/${CHARM_NAME} && charm proof |
132 | + |
133 | +unittests: |
134 | + @echo "Running unit tests" |
135 | + @tox -e unit |
136 | + |
137 | +functional: build |
138 | + @echo "Executing functional tests in ${CHARM_BUILD_DIR}" |
139 | + @CHARM_BUILD_DIR=${CHARM_BUILD_DIR} tox -e func |
140 | + |
141 | +test: lint proof unittests functional |
142 | + @echo "Tests completed for charm ${CHARM_NAME}." |
143 | + |
144 | +# The targets below don't depend on a file |
145 | +.PHONY: help submodules submodules-update clean build release lint black proof unittests functional test |
146 | diff --git a/README.md b/README.md |
147 | new file mode 100644 |
148 | index 0000000..815019b |
149 | --- /dev/null |
150 | +++ b/README.md |
151 | @@ -0,0 +1,22 @@ |
152 | +# Juju strongswan Charm |
153 | + |
154 | +Overview |
155 | +-------- |
156 | + |
157 | +Creates an ipsec tunnel and exposes ports to |
158 | +IP addresses defined in dnat_sockets. |
159 | + |
160 | +Quickstart |
161 | +---------- |
162 | + |
163 | +juju deploy vpn-strongswan --to lxd:0 |
164 | + |
165 | +juju config vpn-strongswan secret="your secret" # default is pre shared key |
166 | +juju config vpn-strongswan local_addr="10.0.3.92" |
167 | +juju config vpn-strongswan remote_addr="66.198.198.198" |
168 | +juju config vpn-strongswan remote_subnet="10.191.0.0/24" |
169 | + |
170 | +Contact |
171 | +------- |
172 | + - Author: bootstack <bootstack@canonical.com> |
173 | + - Bug Tracker: [here](https://bugs.launchpad.net/charm-vpn-strongswan) |
174 | diff --git a/config.yaml b/config.yaml |
175 | new file mode 100644 |
176 | index 0000000..56748dd |
177 | --- /dev/null |
178 | +++ b/config.yaml |
179 | @@ -0,0 +1,58 @@ |
180 | +options: |
181 | + allow_inbound_ssh: |
182 | + type: boolean |
183 | + default: true |
184 | + description: | |
185 | + ip address allowed for inbound access (such as udlap) |
186 | + dnat_sockets: |
187 | + type: string |
188 | + default: "" |
189 | + description: | |
190 | + X.X.X.X:YYYY , X.X.X.X:YYYY , X.X.X.X:YYYY , X.X.X.X:YYYY |
191 | + debug_level: |
192 | + type: int |
193 | + default: 1 |
194 | + description: | |
195 | + The debug level strongSwan service. The levels range from -1 to 4, with -1 being absolutely silent and 4 |
196 | + providing sensitive information (e.g. keys) in the logging. |
197 | + version: |
198 | + type: int |
199 | + default: 2 |
200 | + description: | |
201 | + Method of key exchange; which protocol should be used to initialize the connection. |
202 | + cipher: |
203 | + type: string |
204 | + default: "aes256gcm16-prfsha384-ecp384" |
205 | + description: | |
206 | + Comma-separated list of ESP encryption/authentication algorithms to be used for the connection, e.g. aes128-sha256. |
207 | + auth: |
208 | + type: string |
209 | + default: "psk" |
210 | + description: | |
211 | + Authentication method to use locally (left) or require from the remote (right) side. |
212 | + secret: |
213 | + type: string |
214 | + default: "" |
215 | + description: | |
216 | + comma seperated list of secrets corresponding to auth option |
217 | + ot3jooKuph0Ais2g,fenu5328723j5oo3df |
218 | + local_addr: |
219 | + type: string |
220 | + default: "" |
221 | + description: | |
222 | + The IP address of the participant's public-network interface or one of several magic values. |
223 | + remote_addr: |
224 | + type: string |
225 | + default: "" |
226 | + description: | |
227 | + The IP address of the participant's public-network interface or one of several magic values. |
228 | + remote_subnet: |
229 | + type: string |
230 | + default: "" |
231 | + description: | |
232 | + The IP address of the participant's public-network interface or one of several magic values. |
233 | + vip: |
234 | + type: string |
235 | + default: "" |
236 | + description: | |
237 | + The internal source IP to use in a tunnel, also known as virtual IP. |
238 | diff --git a/copyright b/copyright |
239 | new file mode 100644 |
240 | index 0000000..2591568 |
241 | --- /dev/null |
242 | +++ b/copyright |
243 | @@ -0,0 +1,16 @@ |
244 | +Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0 |
245 | + |
246 | +Files: * |
247 | +Copyright: Copyright 2011-2016, Canonical Ltd., All Rights Reserved. |
248 | +License: Apache-2.0 |
249 | + Licensed under the Apache License, Version 2.0 (the "License"); you may |
250 | + not use this file except in compliance with the License. You may obtain |
251 | + a copy of the License at |
252 | + |
253 | + http://www.apache.org/licenses/LICENSE-2.0 |
254 | + |
255 | + Unless required by applicable law or agreed to in writing, software |
256 | + distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
257 | + WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
258 | + License for the specific language governing permissions and limitations |
259 | + under the License. |
260 | \ No newline at end of file |
261 | diff --git a/hooks/.empty b/hooks/.empty |
262 | new file mode 100644 |
263 | index 0000000..e69de29 |
264 | --- /dev/null |
265 | +++ b/hooks/.empty |
266 | diff --git a/icon.svg b/icon.svg |
267 | new file mode 100644 |
268 | index 0000000..eca73a8 |
269 | --- /dev/null |
270 | +++ b/icon.svg |
271 | @@ -0,0 +1,309 @@ |
272 | +<?xml version="1.0" encoding="UTF-8" standalone="no"?> |
273 | +<!-- Created with Inkscape (http://www.inkscape.org/) --> |
274 | + |
275 | +<svg |
276 | + xmlns:dc="http://purl.org/dc/elements/1.1/" |
277 | + xmlns:cc="http://creativecommons.org/ns#" |
278 | + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" |
279 | + xmlns:svg="http://www.w3.org/2000/svg" |
280 | + xmlns="http://www.w3.org/2000/svg" |
281 | + xmlns:xlink="http://www.w3.org/1999/xlink" |
282 | + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" |
283 | + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" |
284 | + width="96" |
285 | + height="96" |
286 | + id="svg6517" |
287 | + version="1.1" |
288 | + inkscape:version="0.92+devel unknown" |
289 | + sodipodi:docname="openstack-cinder_new.svg" |
290 | + viewBox="0 0 96 96"> |
291 | + <defs |
292 | + id="defs6519"> |
293 | + <linearGradient |
294 | + id="Background"> |
295 | + <stop |
296 | + id="stop4178" |
297 | + offset="0" |
298 | + style="stop-color:#22779e" /> |
299 | + <stop |
300 | + id="stop4180" |
301 | + offset="1" |
302 | + style="stop-color:#2991c0" /> |
303 | + </linearGradient> |
304 | + <filter |
305 | + style="color-interpolation-filters:sRGB" |
306 | + inkscape:label="Inner Shadow" |
307 | + id="filter1121"> |
308 | + <feFlood |
309 | + flood-opacity="0.59999999999999998" |
310 | + flood-color="rgb(0,0,0)" |
311 | + result="flood" |
312 | + id="feFlood1123" /> |
313 | + <feComposite |
314 | + in="flood" |
315 | + in2="SourceGraphic" |
316 | + operator="out" |
317 | + result="composite1" |
318 | + id="feComposite1125" /> |
319 | + <feGaussianBlur |
320 | + in="composite1" |
321 | + stdDeviation="1" |
322 | + result="blur" |
323 | + id="feGaussianBlur1127" /> |
324 | + <feOffset |
325 | + dx="0" |
326 | + dy="2" |
327 | + result="offset" |
328 | + id="feOffset1129" /> |
329 | + <feComposite |
330 | + in="offset" |
331 | + in2="SourceGraphic" |
332 | + operator="atop" |
333 | + result="composite2" |
334 | + id="feComposite1131" /> |
335 | + </filter> |
336 | + <filter |
337 | + style="color-interpolation-filters:sRGB" |
338 | + inkscape:label="Drop Shadow" |
339 | + id="filter950"> |
340 | + <feFlood |
341 | + flood-opacity="0.25" |
342 | + flood-color="rgb(0,0,0)" |
343 | + result="flood" |
344 | + id="feFlood952" /> |
345 | + <feComposite |
346 | + in="flood" |
347 | + in2="SourceGraphic" |
348 | + operator="in" |
349 | + result="composite1" |
350 | + id="feComposite954" /> |
351 | + <feGaussianBlur |
352 | + in="composite1" |
353 | + stdDeviation="1" |
354 | + result="blur" |
355 | + id="feGaussianBlur956" /> |
356 | + <feOffset |
357 | + dx="0" |
358 | + dy="1" |
359 | + result="offset" |
360 | + id="feOffset958" /> |
361 | + <feComposite |
362 | + in="SourceGraphic" |
363 | + in2="offset" |
364 | + operator="over" |
365 | + result="composite2" |
366 | + id="feComposite960" /> |
367 | + </filter> |
368 | + <clipPath |
369 | + clipPathUnits="userSpaceOnUse" |
370 | + id="clipPath873"> |
371 | + <g |
372 | + transform="matrix(0,-0.66666667,0.66604479,0,-258.25992,677.00001)" |
373 | + id="g875" |
374 | + inkscape:label="Layer 1" |
375 | + style="fill:#ff00ff"> |
376 | + <path |
377 | + d="M 46.702703,898.22775 H 97.297297 C 138.16216,898.22775 144,904.06497 144,944.92583 v 50.73846 c 0,40.86071 -5.83784,46.69791 -46.702703,46.69791 H 46.702703 C 5.8378378,1042.3622 0,1036.525 0,995.66429 v -50.73846 c 0,-40.86086 5.8378378,-46.69808 46.702703,-46.69808 z" |
378 | + id="path877" |
379 | + inkscape:connector-curvature="0" |
380 | + sodipodi:nodetypes="sssssssss" /> |
381 | + </g> |
382 | + </clipPath> |
383 | + <style |
384 | + id="style867" |
385 | + type="text/css"><![CDATA[ |
386 | + .fil0 {fill:#1F1A17} |
387 | + ]]></style> |
388 | + <clipPath |
389 | + id="clipPath16"> |
390 | + <path |
391 | + id="path18" |
392 | + d="M -9,-9 H 605 V 222 H -9 Z" |
393 | + inkscape:connector-curvature="0" /> |
394 | + </clipPath> |
395 | + <clipPath |
396 | + id="clipPath116"> |
397 | + <path |
398 | + id="path118" |
399 | + d="m 91.7368,146.3253 -9.7039,-1.577 -8.8548,-3.8814 -7.5206,-4.7308 -7.1566,-8.7335 -4.0431,-4.282 -3.9093,-1.4409 -1.034,2.5271 1.8079,2.6096 0.4062,3.6802 1.211,-0.0488 1.3232,-1.2069 -0.3569,3.7488 -1.4667,0.9839 0.0445,1.4286 -3.4744,-1.9655 -3.1462,-3.712 -0.6559,-3.3176 1.3453,-2.6567 1.2549,-4.5133 2.5521,-1.2084 2.6847,0.1318 2.5455,1.4791 -1.698,-8.6122 1.698,-9.5825 -1.8692,-4.4246 -6.1223,-6.5965 1.0885,-3.941 2.9002,-4.5669 5.4688,-3.8486 2.9007,-0.3969 3.225,-0.1094 -2.012,-8.2601 7.3993,-3.0326 9.2188,-1.2129 3.1535,2.0619 0.2427,5.5797 3.5178,5.8224 0.2426,4.6094 8.4909,-0.6066 7.8843,0.7279 -7.8843,-4.7307 1.3343,-5.701 4.9731,-7.763 4.8521,-2.0622 3.8814,1.5769 1.577,3.1538 8.1269,6.1861 1.5769,-1.3343 12.7363,-0.485 2.5473,2.0619 0.2426,3.6391 -0.849,1.5767 -0.6066,9.8251 -4.2454,8.4909 0.7276,3.7605 2.5475,-1.3343 7.1566,-6.6716 3.5175,-0.2424 3.8815,1.5769 3.8818,2.9109 1.9406,6.3077 11.4021,-0.7277 6.914,2.6686 5.5797,5.2157 4.0028,7.5206 0.9706,8.8546 -0.8493,10.3105 -2.1832,9.2185 -2.1836,2.9112 -3.0322,0.9706 -5.3373,-5.8224 -4.8518,-1.6982 -4.2455,7.0353 -4.2454,3.8815 -2.3049,1.4556 -9.2185,7.6419 -7.3993,4.0028 -7.3993,0.6066 -8.6119,-1.4556 -7.5206,-2.7899 -5.2158,-4.2454 -4.1241,-4.9734 -4.2454,-1.2129" |
400 | + inkscape:connector-curvature="0" /> |
401 | + </clipPath> |
402 | + <clipPath |
403 | + id="clipPath128"> |
404 | + <path |
405 | + id="path130" |
406 | + d="m 91.7368,146.3253 -9.7039,-1.577 -8.8548,-3.8814 -7.5206,-4.7308 -7.1566,-8.7335 -4.0431,-4.282 -3.9093,-1.4409 -1.034,2.5271 1.8079,2.6096 0.4062,3.6802 1.211,-0.0488 1.3232,-1.2069 -0.3569,3.7488 -1.4667,0.9839 0.0445,1.4286 -3.4744,-1.9655 -3.1462,-3.712 -0.6559,-3.3176 1.3453,-2.6567 1.2549,-4.5133 2.5521,-1.2084 2.6847,0.1318 2.5455,1.4791 -1.698,-8.6122 1.698,-9.5825 -1.8692,-4.4246 -6.1223,-6.5965 1.0885,-3.941 2.9002,-4.5669 5.4688,-3.8486 2.9007,-0.3969 3.225,-0.1094 -2.012,-8.2601 7.3993,-3.0326 9.2188,-1.2129 3.1535,2.0619 0.2427,5.5797 3.5178,5.8224 0.2426,4.6094 8.4909,-0.6066 7.8843,0.7279 -7.8843,-4.7307 1.3343,-5.701 4.9731,-7.763 4.8521,-2.0622 3.8814,1.5769 1.577,3.1538 8.1269,6.1861 1.5769,-1.3343 12.7363,-0.485 2.5473,2.0619 0.2426,3.6391 -0.849,1.5767 -0.6066,9.8251 -4.2454,8.4909 0.7276,3.7605 2.5475,-1.3343 7.1566,-6.6716 3.5175,-0.2424 3.8815,1.5769 3.8818,2.9109 1.9406,6.3077 11.4021,-0.7277 6.914,2.6686 5.5797,5.2157 4.0028,7.5206 0.9706,8.8546 -0.8493,10.3105 -2.1832,9.2185 -2.1836,2.9112 -3.0322,0.9706 -5.3373,-5.8224 -4.8518,-1.6982 -4.2455,7.0353 -4.2454,3.8815 -2.3049,1.4556 -9.2185,7.6419 -7.3993,4.0028 -7.3993,0.6066 -8.6119,-1.4556 -7.5206,-2.7899 -5.2158,-4.2454 -4.1241,-4.9734 -4.2454,-1.2129" |
407 | + inkscape:connector-curvature="0" /> |
408 | + </clipPath> |
409 | + <linearGradient |
410 | + id="linearGradient3850" |
411 | + inkscape:collect="always"> |
412 | + <stop |
413 | + id="stop3852" |
414 | + offset="0" |
415 | + style="stop-color:#000000" /> |
416 | + <stop |
417 | + id="stop3854" |
418 | + offset="1" |
419 | + style="stop-color:#000000;stop-opacity:0" /> |
420 | + </linearGradient> |
421 | + <clipPath |
422 | + clipPathUnits="userSpaceOnUse" |
423 | + id="clipPath3095"> |
424 | + <path |
425 | + d="M 976.648,389.551 H 134.246 V 1229.55 H 976.648 V 389.551" |
426 | + id="path3097" |
427 | + inkscape:connector-curvature="0" /> |
428 | + </clipPath> |
429 | + <clipPath |
430 | + clipPathUnits="userSpaceOnUse" |
431 | + id="clipPath3195"> |
432 | + <path |
433 | + d="m 611.836,756.738 -106.34,105.207 c -8.473,8.289 -13.617,20.102 -13.598,33.379 L 598.301,790.207 c -0.031,-13.418 5.094,-25.031 13.535,-33.469" |
434 | + id="path3197" |
435 | + inkscape:connector-curvature="0" /> |
436 | + </clipPath> |
437 | + <clipPath |
438 | + clipPathUnits="userSpaceOnUse" |
439 | + id="clipPath3235"> |
440 | + <path |
441 | + d="m 1095.64,1501.81 c 35.46,-35.07 70.89,-70.11 106.35,-105.17 4.4,-4.38 7.11,-10.53 7.11,-17.55 l -106.37,105.21 c 0,7 -2.71,13.11 -7.09,17.51" |
442 | + id="path3237" |
443 | + inkscape:connector-curvature="0" /> |
444 | + </clipPath> |
445 | + <clipPath |
446 | + id="clipPath4591" |
447 | + clipPathUnits="userSpaceOnUse"> |
448 | + <path |
449 | + inkscape:connector-curvature="0" |
450 | + d="m 1106.6009,730.43734 -0.036,21.648 c -0.01,3.50825 -2.8675,6.61375 -6.4037,6.92525 l -83.6503,7.33162 c -3.5205,0.30763 -6.3812,-2.29987 -6.3671,-5.8145 l 0.036,-21.6475 20.1171,-1.76662 -0.011,4.63775 c 0,1.83937 1.4844,3.19925 3.3262,3.0395 l 49.5274,-4.33975 c 1.8425,-0.166 3.3425,-1.78125 3.3538,-3.626 l 0.01,-4.63025 20.1,-1.7575" |
451 | + style="fill:#ff00ff" |
452 | + id="path4593" /> |
453 | + </clipPath> |
454 | + <radialGradient |
455 | + gradientUnits="userSpaceOnUse" |
456 | + gradientTransform="matrix(-1.4333926,-2.2742838,1.1731823,-0.73941125,-174.08025,98.374394)" |
457 | + r="20.40658" |
458 | + fy="93.399292" |
459 | + fx="-26.508606" |
460 | + cy="93.399292" |
461 | + cx="-26.508606" |
462 | + id="radialGradient3856" |
463 | + xlink:href="#linearGradient3850" |
464 | + inkscape:collect="always" /> |
465 | + <linearGradient |
466 | + gradientTransform="translate(-318.48033,212.32022)" |
467 | + gradientUnits="userSpaceOnUse" |
468 | + y2="993.19702" |
469 | + x2="-51.879555" |
470 | + y1="593.11615" |
471 | + x1="348.20132" |
472 | + id="linearGradient3895" |
473 | + xlink:href="#linearGradient3850" |
474 | + inkscape:collect="always" /> |
475 | + <clipPath |
476 | + id="clipPath3906" |
477 | + clipPathUnits="userSpaceOnUse"> |
478 | + <rect |
479 | + transform="scale(1,-1)" |
480 | + style="color:#000000;opacity:0.8;fill:#ff00ff;stroke-width:4" |
481 | + id="rect3908" |
482 | + width="1019.1371" |
483 | + height="1019.1371" |
484 | + x="357.9816" |
485 | + y="-1725.8152" /> |
486 | + </clipPath> |
487 | + </defs> |
488 | + <sodipodi:namedview |
489 | + id="base" |
490 | + pagecolor="#ffffff" |
491 | + bordercolor="#666666" |
492 | + borderopacity="1.0" |
493 | + inkscape:pageopacity="0.0" |
494 | + inkscape:pageshadow="2" |
495 | + inkscape:zoom="6.3664627" |
496 | + inkscape:cx="41.347717" |
497 | + inkscape:cy="47.487328" |
498 | + inkscape:document-units="px" |
499 | + inkscape:current-layer="layer1" |
500 | + showgrid="true" |
501 | + fit-margin-top="0" |
502 | + fit-margin-left="0" |
503 | + fit-margin-right="0" |
504 | + fit-margin-bottom="0" |
505 | + inkscape:window-width="1920" |
506 | + inkscape:window-height="1029" |
507 | + inkscape:window-x="0" |
508 | + inkscape:window-y="24" |
509 | + inkscape:window-maximized="1" |
510 | + showborder="true" |
511 | + showguides="false" |
512 | + inkscape:guide-bbox="true" |
513 | + inkscape:showpageshadow="false" |
514 | + inkscape:snap-global="true" |
515 | + inkscape:snap-bbox="true" |
516 | + inkscape:bbox-paths="true" |
517 | + inkscape:bbox-nodes="true" |
518 | + inkscape:snap-bbox-edge-midpoints="true" |
519 | + inkscape:snap-bbox-midpoints="true" |
520 | + inkscape:object-paths="true" |
521 | + inkscape:snap-intersection-paths="true" |
522 | + inkscape:object-nodes="true" |
523 | + inkscape:snap-smooth-nodes="true" |
524 | + inkscape:snap-midpoints="true" |
525 | + inkscape:snap-object-midpoints="true" |
526 | + inkscape:snap-center="true" |
527 | + inkscape:document-rotation="0"> |
528 | + <inkscape:grid |
529 | + type="xygrid" |
530 | + id="grid821" /> |
531 | + <sodipodi:guide |
532 | + orientation="1,0" |
533 | + position="16,48" |
534 | + id="guide823" |
535 | + inkscape:locked="false" /> |
536 | + <sodipodi:guide |
537 | + orientation="0,1" |
538 | + position="64,80" |
539 | + id="guide825" |
540 | + inkscape:locked="false" /> |
541 | + <sodipodi:guide |
542 | + orientation="1,0" |
543 | + position="80,40" |
544 | + id="guide827" |
545 | + inkscape:locked="false" /> |
546 | + <sodipodi:guide |
547 | + orientation="0,1" |
548 | + position="64,16" |
549 | + id="guide829" |
550 | + inkscape:locked="false" /> |
551 | + </sodipodi:namedview> |
552 | + <metadata |
553 | + id="metadata6522"> |
554 | + <rdf:RDF> |
555 | + <cc:Work |
556 | + rdf:about=""> |
557 | + <dc:format>image/svg+xml</dc:format> |
558 | + <dc:type |
559 | + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> |
560 | + <dc:title /> |
561 | + </cc:Work> |
562 | + </rdf:RDF> |
563 | + </metadata> |
564 | + <g |
565 | + inkscape:label="BACKGROUND" |
566 | + inkscape:groupmode="layer" |
567 | + id="layer1" |
568 | + transform="translate(268,-635.29076)"> |
569 | + <path |
570 | + style="fill:#ed1843" |
571 | + d="M 48 0 A 48 48 0 0 0 0 48 A 48 48 0 0 0 48 96 A 48 48 0 0 0 96 48 A 48 48 0 0 0 48 0 z " |
572 | + transform="translate(-268,635.29076)" |
573 | + id="path6455" /> |
574 | + <path |
575 | + style="fill:#ffffff;stroke-width:0.13333333" |
576 | + d="M 25.203125 20 C 22.340895 20 20 22.341395 20 25.203125 L 20 32 L 20 36 L 76 36 L 76 32 L 76 25.203125 C 76 22.341395 73.658635 20 70.796875 20 L 25.203125 20 z M 20 40 L 20 42 L 20 54 L 20 56 L 76 56 L 76 54 L 76 42 L 76 40 L 20 40 z M 20 60 L 20 64 L 20 70.796875 C 20 73.658605 22.341375 76 25.203125 76 L 70.796875 76 C 73.659105 76 76 73.658605 76 70.796875 L 76 64 L 76 60 L 20 60 z " |
577 | + transform="translate(-268,635.29076)" |
578 | + id="path38" /> |
579 | + </g> |
580 | +</svg> |
581 | diff --git a/lib/__init__.py b/lib/__init__.py |
582 | new file mode 100644 |
583 | index 0000000..7106537 |
584 | --- /dev/null |
585 | +++ b/lib/__init__.py |
586 | @@ -0,0 +1 @@ |
587 | +"""Lib module.""" |
588 | diff --git a/lib/lib_strongswan.py b/lib/lib_strongswan.py |
589 | new file mode 100644 |
590 | index 0000000..ff34832 |
591 | --- /dev/null |
592 | +++ b/lib/lib_strongswan.py |
593 | @@ -0,0 +1,426 @@ |
594 | +"""Class for the management of swantctl.""" |
595 | + |
596 | +# Copyright © 2020 bootstack bootstack@canonical.com |
597 | + |
598 | +import hashlib |
599 | +import logging |
600 | +import os |
601 | +import subprocess |
602 | +from ipaddress import AddressValueError, IPv4Address, ip_network |
603 | +from pathlib import Path |
604 | + |
605 | +import distro |
606 | + |
607 | +from jinja2 import Environment, FileSystemLoader |
608 | + |
609 | +from charmhelpers.fetch import apt_install # noqa:E402 |
610 | + |
611 | +import netifaces |
612 | + |
613 | +from strongswan_constants import ( |
614 | + AVAILABLE_CIPHERS, |
615 | + CHARON_DEBUG_KEYS, |
616 | + OPTION_VALIDATORS, |
617 | +) |
618 | + |
619 | + |
620 | +logger = logging.getLogger() |
621 | + |
622 | + |
623 | +def get_default_route_subnet(): |
624 | + """Get the CIDR notation of the default network. |
625 | + |
626 | + Returns |
627 | + CIDR: eg. 192.168.0.0/16 |
628 | + """ |
629 | + gateways = netifaces.gateways() |
630 | + interface = gateways["default"][netifaces.AF_INET][1] |
631 | + addresses = netifaces.ifaddresses(interface) |
632 | + ipaddress = addresses[netifaces.AF_INET][0]["addr"] |
633 | + mask = addresses[netifaces.AF_INET][0]["netmask"] |
634 | + net = ip_network(ipaddress + "/" + mask, strict=False) |
635 | + return net |
636 | + |
637 | + |
638 | +def file_get_contents(filename): |
639 | + """Create file or return its content. |
640 | + |
641 | + @param filename: path to file to be read or created empty |
642 | + @type filename: str |
643 | + @return Text file content or an empty string |
644 | + @rtype str |
645 | + """ |
646 | + if os.path.exists(filename) is False: |
647 | + with open(filename, "a"): |
648 | + os.utime(filename, None) |
649 | + return "".encode("utf-8") |
650 | + |
651 | + with open(filename) as f: |
652 | + return f.read().encode("utf-8") |
653 | + |
654 | + |
655 | +def is_string_in_file(str_to_check, file): |
656 | + """Look up for a string in a text file.""" |
657 | + with open(file) as f: |
658 | + return str_to_check in f.read() |
659 | + |
660 | + |
661 | +class StrongSwanError(Exception): |
662 | + """Base strongSwan error class.""" |
663 | + |
664 | + def __init__(self, workload_status): |
665 | + """Init StrongSwanError with a workload_status.""" |
666 | + super().__init__() |
667 | + self.workload_status = workload_status |
668 | + |
669 | + def __repr__(self): |
670 | + """Return the canonical string representation of the object.""" |
671 | + return "{}: {}".format(type(self).__name__, self.workload_status) |
672 | + |
673 | + def __str__(self): |
674 | + """Return the error message raised.""" |
675 | + return self.workload_status |
676 | + |
677 | + |
678 | +class StrongSwanValidationError(StrongSwanError): |
679 | + """Raised when juju config validation fails.""" |
680 | + |
681 | + pass |
682 | + |
683 | + |
684 | +class StrongSwanServiceError(StrongSwanError): |
685 | + """Raised when service-related command fails.""" |
686 | + |
687 | + pass |
688 | + |
689 | + |
690 | +class StrongSwanManager: |
691 | + """StrongSwanManager install and manage swantctl.""" |
692 | + |
693 | + STRONGSWAN_PACKAGES = ( |
694 | + "libstrongswan", |
695 | + "libstrongswan-standard-plugins", |
696 | + "strongswan", |
697 | + "strongswan-charon", |
698 | + "strongswan-libcharon", |
699 | + "charon-systemd", |
700 | + "strongswan-swanctl", |
701 | + "apparmor-utils", |
702 | + ) |
703 | + SWANCTL_CONF_PATH = "/etc/swanctl.conf" |
704 | + STRONGSWAN_CONF_PATH = "/etc/strongswan.conf" |
705 | + IPTABLES_SAVE_PATH = "/etc/iptables.save" |
706 | + swanctl_restart_required = False |
707 | + _strongswan_service = None |
708 | + |
709 | + def __init__(self, unit): |
710 | + """Install and manage swanctl. |
711 | + |
712 | + Args: |
713 | + unit: A reference to the charm application unit |
714 | + Returns: |
715 | + Boolean: true or false |
716 | + """ |
717 | + self.unit = unit |
718 | + |
719 | + @property |
720 | + def strongswan_service(self): |
721 | + """Return strongSwan service string based on distro.""" |
722 | + if self._strongswan_service: |
723 | + return self._strongswan_service |
724 | + codename = distro.codename() |
725 | + if codename == "focal": |
726 | + self._strongswan_service = "strongswan.service" |
727 | + else: |
728 | + self._strongswan_service = "strongswan-swanctl.service" |
729 | + return self._strongswan_service |
730 | + |
731 | + @staticmethod |
732 | + def modify_app_armor_profile(): |
733 | + """Modify related strongSwan app armor profiles, setting to complain mode. |
734 | + |
735 | + NOTE: currently, app armor profile for strongSwan related services |
736 | + are all set to complain to allow for full functionality but still |
737 | + get some audit of what needs configuring. ideally, these services |
738 | + would have profiles that dictate access to things as needed. |
739 | + """ |
740 | + app_armor_profile = "/etc/apparmor.d/usr.sbin.swanctl" |
741 | + |
742 | + if not os.path.exists(app_armor_profile): |
743 | + return |
744 | + |
745 | + app_armor_profiles = [ |
746 | + "usr.lib.ipsec.charon", |
747 | + "usr.lib.ipsec.stroke", |
748 | + "usr.sbin.charon-systemd", |
749 | + "usr.sbin.swanctl", |
750 | + ] |
751 | + app_armor_profile_path = Path("/etc/apparmor.d") |
752 | + for app_armor_profile in app_armor_profiles: |
753 | + cmd = "aa-complain {}".format( |
754 | + app_armor_profile_path / app_armor_profile |
755 | + ).split() |
756 | + subprocess.check_output(cmd, stderr=subprocess.STDOUT) |
757 | + |
758 | + def install_strongswan(self): |
759 | + """Install the strongswan packages.""" |
760 | + if os.path.exists("/usr/sbin/swanctl"): |
761 | + return True |
762 | + try: |
763 | + apt_install(self.STRONGSWAN_PACKAGES) |
764 | + except subprocess.CalledProcessError as apt_install_error: |
765 | + logger.error("apt_install call failed: {}".format(apt_install_error)) |
766 | + raise StrongSwanServiceError( |
767 | + workload_status="Failed to apt install packages." |
768 | + ) |
769 | + self.modify_app_armor_profile() |
770 | + |
771 | + def enable_strongswan(self): |
772 | + """Enable the systemd swanctl service.""" |
773 | + cmd = "systemctl enable {}".format(self.strongswan_service).split() |
774 | + try: |
775 | + subprocess.check_output(cmd, stderr=subprocess.STDOUT) |
776 | + except subprocess.CalledProcessError as e: |
777 | + logger.error("enable strongswan cmd {} failed: {}".format(cmd, e)) |
778 | + msg = "Failed to enable {}. Check juju logs to troubleshoot".format( |
779 | + self.strongswan_service |
780 | + ) |
781 | + raise StrongSwanServiceError(workload_status=msg) |
782 | + |
783 | + def restart_strongswan(self): |
784 | + """Restart the systemd swanctl service.""" |
785 | + try: |
786 | + if self.swanctl_restart_required: |
787 | + cmd = "systemctl restart {}".format(self.strongswan_service).split() |
788 | + subprocess.check_output(cmd, stderr=subprocess.STDOUT) |
789 | + self.swanctl_restart_required = False |
790 | + except subprocess.CalledProcessError as e: |
791 | + logger.error( |
792 | + "Failed to restart {}: {}".format(self.strongswan_service, e.output) |
793 | + ) |
794 | + msg = "Failed to restart {}. Check juju logs to troubleshoot.".format( |
795 | + self.strongswan_service |
796 | + ) |
797 | + raise StrongSwanServiceError(workload_status=msg) |
798 | + |
799 | + def validate_proposals(self, key): |
800 | + """Validate a comma-separated list of ESP encryption/authentication algorithms. |
801 | + |
802 | + https://wiki.strongswan.org/projects/strongswan/wiki/SecurityRecommendations |
803 | + Args: |
804 | + key: the lvalue to lookup in the application model |
805 | + Returns: |
806 | + The validated or modified value |
807 | + """ |
808 | + ciphers = set() |
809 | + for value in self.unit.model.config.get(key, "").split(","): |
810 | + ciphers.add(value.strip()) |
811 | + |
812 | + invalid_ciphers = ciphers - AVAILABLE_CIPHERS |
813 | + if invalid_ciphers: |
814 | + raise StrongSwanValidationError( |
815 | + workload_status="Unsupported encryption algorithm: {}".format( |
816 | + invalid_ciphers |
817 | + ) |
818 | + ) |
819 | + |
820 | + return self.unit.model.config[key] |
821 | + |
822 | + @staticmethod |
823 | + def generate_debug_from_level(debug_level): |
824 | + """Generate debug level for each daemon. |
825 | + |
826 | + @param debug_level: integer between -1 and 4 |
827 | + @type debug_devel: int |
828 | + @return dict mapping charon_key to debug_level |
829 | + @rtype dict |
830 | + """ |
831 | + return {charon_key: debug_level for charon_key in CHARON_DEBUG_KEYS} |
832 | + |
833 | + def validate_charon_debug(self, key): |
834 | + """Validate the charon debug level. |
835 | + |
836 | + https://wiki.strongswan.org/projects/strongswan/wiki/LoggerConfiguration |
837 | + |
838 | + Args: |
839 | + key: the lvalue to lookup in the application model. |
840 | + if the value is integer, create the associated log levels |
841 | + the user can set level (-1..4) |
842 | + if the value is string of levels, parse it to confirm valid config |
843 | + app=1 asn=4 imc=2 |
844 | + Returns: |
845 | + The validated value |
846 | + """ |
847 | + debug_level = self.unit.model.config.get(key) |
848 | + min_level, max_level = -1, 4 |
849 | + if min_level <= debug_level <= max_level: |
850 | + return self.generate_debug_from_level(debug_level) |
851 | + else: |
852 | + msg = ( |
853 | + "Supported debug levels for charon_debug are between -1 and 4. " |
854 | + "Level {} is invalid".format(debug_level) |
855 | + ) |
856 | + raise StrongSwanValidationError(workload_status=msg) |
857 | + |
858 | + def validate_ip(self, key): |
859 | + """Validate an ip address. |
860 | + |
861 | + Args: |
862 | + key: the config option to lookup in the application model |
863 | + Returns: |
864 | + The validated value |
865 | + """ |
866 | + ip_addr = self.unit.model.config[key] |
867 | + try: |
868 | + IPv4Address(ip_addr) |
869 | + return self.unit.model.config[key] |
870 | + except AddressValueError as ipv4_error: |
871 | + raise StrongSwanValidationError( |
872 | + workload_status='Invalid IP address "{}" for option "{}": {}'.format( |
873 | + ip_addr, key, ipv4_error |
874 | + ) |
875 | + ) |
876 | + |
877 | + def validate_cidr(self, key): |
878 | + """Validate CIDR address representation. |
879 | + |
880 | + Args: |
881 | + key: the config option to lookup in the application model |
882 | + Returns: |
883 | + The validated or modified value |
884 | + """ |
885 | + config_value = self.unit.model.config[key] |
886 | + try: |
887 | + return str(ip_network(config_value)) |
888 | + except ValueError as ip_network_value_error: |
889 | + raise StrongSwanValidationError( |
890 | + workload_status='Invalid CIDR "{}" for "{}": {}'.format( |
891 | + config_value, key, ip_network_value_error |
892 | + ) |
893 | + ) |
894 | + |
895 | + def validate_secrets(self, key): |
896 | + """Validate secrets. |
897 | + |
898 | + Args: |
899 | + key: the config option to lookup in the application model |
900 | + Returns: |
901 | + list of secrets |
902 | + """ |
903 | + config_value = self.unit.model.config[key] |
904 | + cleaned_secrets = [ |
905 | + secret.strip() for secret in config_value.split(",") if secret |
906 | + ] |
907 | + if cleaned_secrets: |
908 | + return cleaned_secrets |
909 | + else: |
910 | + raise StrongSwanValidationError( |
911 | + workload_status="No valid secrets found. " |
912 | + '"secret" requires at least 1 secret set' |
913 | + ) |
914 | + |
915 | + def generate_jinja_context(self): |
916 | + """Generate the context to be passed to jinja for config generation. |
917 | + |
918 | + strongswan.org/testing/testresults/swanctl/net2net-psk/sun.swanctl.conf |
919 | + |
920 | + Here we lookup the juju config item and apply the associated validation |
921 | + Rvalues: |
922 | + None (juju types, or handled elsewhere) |
923 | + array (accepted values) |
924 | + string (function pointer ofr complex validation) |
925 | + """ |
926 | + context = {} |
927 | + context["charm_name"] = self.unit.model.app.name |
928 | + |
929 | + config_options_to_check = ( |
930 | + option |
931 | + for option in self.unit.model.config |
932 | + if option and option in OPTION_VALIDATORS |
933 | + ) |
934 | + # check option against its validator |
935 | + for key in config_options_to_check: |
936 | + validator = OPTION_VALIDATORS.get(key) |
937 | + |
938 | + # option has known list of accepted values |
939 | + if isinstance(validator, list): |
940 | + logger.debug("Validating set value is available option.") |
941 | + if self.unit.model.config[key] not in validator: |
942 | + raise StrongSwanValidationError( |
943 | + workload_status="Valid values for {} are: {}".format( |
944 | + key, ", ".join(validator) |
945 | + ) |
946 | + ) |
947 | + |
948 | + # validator is a function pointer |
949 | + if isinstance(validator, str): |
950 | + validator_func = getattr(self, validator) |
951 | + if self.unit.model.config[key]: |
952 | + context[key] = validator_func(key) |
953 | + |
954 | + context["local_subnet"] = get_default_route_subnet() |
955 | + |
956 | + return context |
957 | + |
958 | + def generate_strongswan_config(self): |
959 | + """Create config files from jinja templates and update files on disk. |
960 | + |
961 | + if changes have occured |
962 | + """ |
963 | + swanctl_old_file_contents = file_get_contents(self.SWANCTL_CONF_PATH) |
964 | + swanctl_old_file_contents_md5sum = hashlib.md5( |
965 | + swanctl_old_file_contents |
966 | + ).hexdigest() |
967 | + strongswan_old_file_contents = file_get_contents(self.STRONGSWAN_CONF_PATH) |
968 | + strongswan_old_file_contents_md5sum = hashlib.md5( |
969 | + strongswan_old_file_contents |
970 | + ).hexdigest() |
971 | + |
972 | + context = self.generate_jinja_context() |
973 | + |
974 | + # Update swanctl.conf if the file has changed |
975 | + env = Environment(loader=FileSystemLoader("templates")) |
976 | + swanctl_new_config_template = env.get_template("swanctl.conf.j2") |
977 | + swanctl_new_config = swanctl_new_config_template.render({"conf": context}) |
978 | + |
979 | + # remove blank files |
980 | + swanctl_new_config = os.linesep.join( |
981 | + [s for s in swanctl_new_config.splitlines() if s.strip() != ""] |
982 | + ) |
983 | + swanctl_new_file_contents_md5sum = hashlib.md5( |
984 | + swanctl_new_config.encode("utf-8") |
985 | + ).hexdigest() |
986 | + |
987 | + # sometimes its handy to see the mtime of a file |
988 | + # we dont actually need to write the file unless its actually changed |
989 | + if swanctl_new_file_contents_md5sum == swanctl_old_file_contents_md5sum: |
990 | + logger.info("Swanctl config unchanged") |
991 | + else: |
992 | + # secrets changed |
993 | + with open(self.SWANCTL_CONF_PATH, "w", encoding="utf-8") as f_out: |
994 | + f_out.write(swanctl_new_config) |
995 | + self.swanctl_restart_required = True |
996 | + logger.info("Swanctl config file changed") |
997 | + |
998 | + # Update strongswan.conf if the file has changed |
999 | + strongswan_new_config_template = env.get_template("strongswan.conf.j2") |
1000 | + strongswan_new_config = strongswan_new_config_template.render({"conf": context}) |
1001 | + |
1002 | + # remove blank files |
1003 | + strongswan_new_config = os.linesep.join( |
1004 | + [s for s in strongswan_new_config.splitlines() if s.strip() != ""] |
1005 | + ) |
1006 | + strongswan_new_file_contents_md5sum = hashlib.md5( |
1007 | + strongswan_new_config.encode("utf-8") |
1008 | + ).hexdigest() |
1009 | + |
1010 | + # sometimes its handy to see the mtime of a file |
1011 | + # we dont actually need to write the file unless its actually changed |
1012 | + if strongswan_new_file_contents_md5sum == strongswan_old_file_contents_md5sum: |
1013 | + logger.info("Strongswan config unchanged") |
1014 | + else: |
1015 | + # secrets changed |
1016 | + with open(self.STRONGSWAN_CONF_PATH, "w", encoding="utf-8") as f_out: |
1017 | + f_out.write(strongswan_new_config) |
1018 | + self.swanctl_restart_required = True |
1019 | + logger.info("Strongswan config file changed") |
1020 | diff --git a/lib/strongswan_constants.py b/lib/strongswan_constants.py |
1021 | new file mode 100644 |
1022 | index 0000000..493526a |
1023 | --- /dev/null |
1024 | +++ b/lib/strongswan_constants.py |
1025 | @@ -0,0 +1,49 @@ |
1026 | +"""Constants used by the StrongswanManager class.""" |
1027 | + |
1028 | +OPTION_VALIDATORS = { |
1029 | + "debug_level": "validate_charon_debug", |
1030 | + "version": [0, 1, 2], |
1031 | + "cipher": "validate_proposals", |
1032 | + "auth": ["pubkey", "psk", "eap", "xauth"], |
1033 | + "secret": "validate_secrets", |
1034 | + "local_addr": "validate_ip", |
1035 | + "remote_addr": "validate_ip", |
1036 | + "remote_subnet": "validate_cidr", |
1037 | + "vip": "validate_ip", |
1038 | +} |
1039 | + |
1040 | +CHARON_DEBUG_KEYS = ( |
1041 | + "app", |
1042 | + "asn", |
1043 | + "cfg", |
1044 | + "chd", |
1045 | + "dmn", |
1046 | + "enc", |
1047 | + "esp", |
1048 | + "ike", |
1049 | + "imc", |
1050 | + "imv", |
1051 | + "job", |
1052 | + "knl", |
1053 | + "lib", |
1054 | + "mgr", |
1055 | + "net", |
1056 | + "pts", |
1057 | + "tls", |
1058 | + "tnc", |
1059 | +) |
1060 | + |
1061 | +AVAILABLE_CIPHERS = { |
1062 | + "aes128-sha256-modp2048", |
1063 | + "aes256gcm16-ecp384", |
1064 | + "aes256gcm16-prfsha384-ecp384", |
1065 | + "aes256-sha2_256-modp1536", |
1066 | + "aes256-sha256", |
1067 | + "aes256-sha256-modp1536", |
1068 | + "aes256-sha256-modp2048", |
1069 | + "aes256-sha512", |
1070 | + "aes256-sha512-modp1536", |
1071 | + "aes256-sha512-modp2048", |
1072 | + "aes-sha256-ecp256", |
1073 | + "aes-sha256-ecp256", |
1074 | +} |
1075 | diff --git a/lib/vpncommon b/lib/vpncommon |
1076 | new file mode 120000 |
1077 | index 0000000..8555a68 |
1078 | --- /dev/null |
1079 | +++ b/lib/vpncommon |
1080 | @@ -0,0 +1 @@ |
1081 | +../mod/vpncommon/ |
1082 | \ No newline at end of file |
1083 | diff --git a/metadata.yaml b/metadata.yaml |
1084 | new file mode 100644 |
1085 | index 0000000..8c0dc91 |
1086 | --- /dev/null |
1087 | +++ b/metadata.yaml |
1088 | @@ -0,0 +1,22 @@ |
1089 | +name: vpn-strongswan |
1090 | +display-name: strongSwan VPN |
1091 | +summary: Installs and configures strongSwan ipsec client |
1092 | +maintainers: |
1093 | + - bootstack <bootstack@canonical.com> |
1094 | + - david.o.neill <david.o.neill@canonical.com> |
1095 | + - Zachary Zehring <zachary.zehring@canonical.com> |
1096 | +description: | |
1097 | + This charm is meant to be deployed into a container on physical host or VM. |
1098 | + The charm installs and configures strongSwan to the minimum spec and provides |
1099 | + the ability to do port forwarding via the container in the forward of iptables |
1100 | + rules. |
1101 | +tags: |
1102 | + - misc |
1103 | +series: |
1104 | + - bionic |
1105 | + - focal |
1106 | +provides: |
1107 | + vpn: |
1108 | + interface: vpn |
1109 | +extra-bindings: |
1110 | + default: |
1111 | diff --git a/mod/vpncommon b/mod/vpncommon |
1112 | new file mode 160000 |
1113 | index 0000000..9a74b6b |
1114 | --- /dev/null |
1115 | +++ b/mod/vpncommon |
1116 | @@ -0,0 +1 @@ |
1117 | +Subproject commit 9a74b6b8176f81c0bfc46c7d63b26e6a00e55b3a |
1118 | diff --git a/requirements.txt b/requirements.txt |
1119 | new file mode 100644 |
1120 | index 0000000..6b12403 |
1121 | --- /dev/null |
1122 | +++ b/requirements.txt |
1123 | @@ -0,0 +1,4 @@ |
1124 | +charmhelpers |
1125 | +jinja2 |
1126 | +ops |
1127 | +distro |
1128 | \ No newline at end of file |
1129 | diff --git a/src/charm.py b/src/charm.py |
1130 | new file mode 100755 |
1131 | index 0000000..d957daf |
1132 | --- /dev/null |
1133 | +++ b/src/charm.py |
1134 | @@ -0,0 +1,168 @@ |
1135 | +#! /usr/bin/env python3 |
1136 | +# -*- coding: utf-8 -*- |
1137 | +# vim:fenc=utf-8 |
1138 | +# Copyright © 2020 bootstack bootstack@canonical.com |
1139 | +"""Strong swan vpn charm, installs and configure strongswan.""" |
1140 | +import logging |
1141 | + |
1142 | +from charmhelpers.fetch import apt_install |
1143 | +from charmhelpers.fetch.python.packages import pip_install |
1144 | + |
1145 | +from ops.charm import CharmBase |
1146 | +from ops.framework import StoredState |
1147 | +from ops.main import main |
1148 | +from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus |
1149 | + |
1150 | +# ugly hack needed for installing apt packages until operator framework figures |
1151 | +# out how to handle it |
1152 | +# https://github.com/canonical/operator/issues/156 |
1153 | +try: |
1154 | + from lib_strongswan import ( |
1155 | + StrongSwanManager, |
1156 | + StrongSwanValidationError, |
1157 | + StrongSwanServiceError, |
1158 | + ) |
1159 | + from vpncommon.VPNProvides import VPNProvides # noqa:E402 |
1160 | + from vpncommon.NetFilterManager import NetFilterManager |
1161 | +except (ImportError, AttributeError): |
1162 | + apt_install(["python3-distutils", "python3-numpy"]) |
1163 | + pip_install(["python-iptables"]) |
1164 | + from lib_strongswan import StrongSwanManager |
1165 | + from vpncommon.VPNProvides import VPNProvides # noqa:E402 |
1166 | + from vpncommon.NetFilterManager import NetFilterManager |
1167 | + |
1168 | + |
1169 | +REQUIRED_OPTIONS = ["secret", "local_addr", "remote_addr", "remote_subnet"] |
1170 | + |
1171 | + |
1172 | +class VpnStrongswanCharm(CharmBase): |
1173 | + """Install and configures swanctl and associated netfilter rules. |
1174 | + |
1175 | + Inherits: |
1176 | + CharmBase: Operator framework |
1177 | + """ |
1178 | + |
1179 | + state = StoredState() |
1180 | + |
1181 | + def __init__(self, *args): |
1182 | + """Initialize charm and configure states and events to observe. |
1183 | + |
1184 | + Args: |
1185 | + args (tuple): addtional partmaters used in setup |
1186 | + """ |
1187 | + super().__init__(*args) |
1188 | + self.logger = logging.getLogger() |
1189 | + |
1190 | + # Instantiate helpers |
1191 | + self.vpn = VPNProvides(self, "vpn") |
1192 | + # delayed import due to c-bindings and apt install above of python libraries |
1193 | + |
1194 | + self.strongSwanManager = StrongSwanManager(self) |
1195 | + self.netFilterManager = NetFilterManager() |
1196 | + |
1197 | + # subscribe to observer events |
1198 | + self.framework.observe(self.on.install, self.on_install) |
1199 | + self.framework.observe(self.on.upgrade_charm, self.on_install) |
1200 | + self.framework.observe(self.on.config_changed, self.on_config_changed) |
1201 | + self.framework.observe(self.on.update_status, self.on_update_status) |
1202 | + |
1203 | + # initialize states |
1204 | + self.state.set_default(installed=False) |
1205 | + self.state.set_default(configured=False) |
1206 | + self.state.set_default(started=False) |
1207 | + self.state.set_default(enabled=False) |
1208 | + |
1209 | + def on_update_status(self, event): |
1210 | + """Handle update status event.""" |
1211 | + return |
1212 | + |
1213 | + def on_install(self, event): |
1214 | + """Handle the install event. |
1215 | + |
1216 | + Args: |
1217 | + event (event): Operator framework event |
1218 | + """ |
1219 | + self.unit.status = MaintenanceStatus("Installing charm software") |
1220 | + try: |
1221 | + self.strongSwanManager.install_strongswan() |
1222 | + except StrongSwanServiceError as install_error: |
1223 | + self.unit.status = BlockedStatus(install_error.workload_status) |
1224 | + return |
1225 | + self.state.installed = True |
1226 | + self.unit.status = MaintenanceStatus("Install complete") |
1227 | + |
1228 | + def on_config_changed(self, event): |
1229 | + """Handle config changed event. |
1230 | + |
1231 | + Args: |
1232 | + event (event): Operator framework event |
1233 | + """ |
1234 | + self.unit.status = MaintenanceStatus("Configuring charm software") |
1235 | + |
1236 | + missing_required_options = self._get_missing_required_options() |
1237 | + if missing_required_options: |
1238 | + self.unit.status = BlockedStatus( |
1239 | + "Following options need to be set: {}".format(missing_required_options) |
1240 | + ) |
1241 | + return |
1242 | + |
1243 | + try: |
1244 | + self.strongSwanManager.generate_strongswan_config() |
1245 | + except StrongSwanValidationError as validation_error: |
1246 | + self.logger.warning( |
1247 | + "Encountered validation exception: {}".format( |
1248 | + validation_error.workload_status |
1249 | + ) |
1250 | + ) |
1251 | + self.unit.status = BlockedStatus(validation_error.workload_status) |
1252 | + return |
1253 | + |
1254 | + self.unit.status = MaintenanceStatus("Configuring iptables") |
1255 | + result = self.netFilterManager.generate_iptables_config( |
1256 | + self.model.config["dnat_sockets"], |
1257 | + self.model.config["remote_subnet"], |
1258 | + self.model.config["allow_inbound_ssh"], |
1259 | + ) |
1260 | + if isinstance(result, str): |
1261 | + self.unit.status = BlockedStatus(result) |
1262 | + return |
1263 | + self.unit.status = MaintenanceStatus("Iptables configured") |
1264 | + |
1265 | + try: |
1266 | + self._enable_strongswan_if_required() |
1267 | + self._restart_strongswan_if_required() |
1268 | + except StrongSwanServiceError as enable_error: |
1269 | + self.unit.status = BlockedStatus(enable_error.workload_status) |
1270 | + return |
1271 | + |
1272 | + self.state.configured = True |
1273 | + self.unit.status = ActiveStatus("Unit is ready") |
1274 | + |
1275 | + def _enable_strongswan_if_required(self): |
1276 | + if self.state.enabled: |
1277 | + return |
1278 | + |
1279 | + self.unit.status = MaintenanceStatus("Enabling strongSwan service") |
1280 | + self.strongSwanManager.enable_strongswan() |
1281 | + self.unit.status = MaintenanceStatus("Enabled strongSwan service") |
1282 | + self.state.enabled = True |
1283 | + |
1284 | + def _restart_strongswan_if_required(self): |
1285 | + if not self.strongSwanManager.swanctl_restart_required: |
1286 | + return |
1287 | + |
1288 | + self.unit.status = MaintenanceStatus("Restarting strongSwan service") |
1289 | + self.strongSwanManager.restart_strongswan() |
1290 | + self.unit.status = MaintenanceStatus("Restarted strongSwan service") |
1291 | + |
1292 | + def _get_missing_required_options(self): |
1293 | + """Return list of config options not set in config that are required.""" |
1294 | + missing_required_options = [] |
1295 | + for option in REQUIRED_OPTIONS: |
1296 | + if not self.model.config.get(option): |
1297 | + missing_required_options.append(option) |
1298 | + return missing_required_options |
1299 | + |
1300 | + |
1301 | +if __name__ == "__main__": |
1302 | + main(VpnStrongswanCharm) |
1303 | diff --git a/templates/strongswan.conf.j2 b/templates/strongswan.conf.j2 |
1304 | new file mode 100644 |
1305 | index 0000000..37cd4db |
1306 | --- /dev/null |
1307 | +++ b/templates/strongswan.conf.j2 |
1308 | @@ -0,0 +1,27 @@ |
1309 | +charon { |
1310 | + filelog { |
1311 | + charon { |
1312 | + path = /var/log/ipsec-{{ conf.charm_name }}.log |
1313 | + time_format = %b %e %T |
1314 | + ike_name = yes |
1315 | + append = no |
1316 | + default = 0 |
1317 | + flush_line = yes |
1318 | + } |
1319 | + stderr { |
1320 | + {% for charon_debug_key, charon_debug_level in conf.debug_level.items() %} |
1321 | + {{ charon_debug_key }}={{ charon_debug_level }} |
1322 | + {% endfor %} |
1323 | + } |
1324 | + } |
1325 | + syslog { |
1326 | + identifier = {{ conf.charm_name }} |
1327 | + daemon { |
1328 | + } |
1329 | + auth { |
1330 | + {% for charon_debug_key, charon_debug_level in conf.debug_level.items() %} |
1331 | + {{ charon_debug_key }}={{ charon_debug_level }} |
1332 | + {% endfor %} |
1333 | + } |
1334 | + } |
1335 | +} |
1336 | \ No newline at end of file |
1337 | diff --git a/templates/swanctl.conf.j2 b/templates/swanctl.conf.j2 |
1338 | new file mode 100644 |
1339 | index 0000000..872340f |
1340 | --- /dev/null |
1341 | +++ b/templates/swanctl.conf.j2 |
1342 | @@ -0,0 +1,57 @@ |
1343 | +{# https://www.strongswan.org/testing/testresults/swanctl/net2net-psk/sun.swanctl.conf #} |
1344 | +connections { |
1345 | + {{ conf.charm_name }} { |
1346 | + {% if conf.local_addr is defined %} |
1347 | + local_addrs = {{ conf.local_addr }} |
1348 | + {% endif %} |
1349 | + {% if conf.remote_addr is defined %} |
1350 | + remote_addrs = {{ conf.remote_addr }} |
1351 | + {% endif %} |
1352 | + {% if conf.version is defined %} |
1353 | + version = {{ conf.version }} |
1354 | + {% endif %} |
1355 | + {% if conf.cipher is defined %} |
1356 | + proposals = {{ conf.cipher }} |
1357 | + {% endif %} |
1358 | + |
1359 | + local-1 { |
1360 | + id = local |
1361 | + {% if conf.auth is defined %} |
1362 | + auth = {{ conf.auth }} |
1363 | + {% endif %} |
1364 | + } |
1365 | + remote-1 { |
1366 | + id = remote |
1367 | + {% if conf.auth is defined %} |
1368 | + auth = {{ conf.auth }} |
1369 | + {% endif %} |
1370 | + } |
1371 | + |
1372 | + children { |
1373 | + net-net { |
1374 | + {% if conf.local_subnet is defined %} |
1375 | + local_ts = {{ conf.local_subnet }} |
1376 | + {% endif %} |
1377 | + {% if conf.remote_subnet is defined %} |
1378 | + remote_ts = {{ conf.remote_subnet }} |
1379 | + {% endif %} |
1380 | + {% if conf.cipher is defined %} |
1381 | + esp_proposals = {{ conf.cipher }} |
1382 | + {% endif %} |
1383 | + {% if conf.vip is defined %} |
1384 | + vips = {{ conf.vip }} |
1385 | + {% endif %} |
1386 | + } |
1387 | + } |
1388 | + } |
1389 | +} |
1390 | + |
1391 | +secrets { |
1392 | + {% for secret in conf.secret %} |
1393 | + {{ conf.charm_name }}-{{ loop.index }} { |
1394 | + id-local = local |
1395 | + id-remote = remote |
1396 | + secret = "{{ secret }}" |
1397 | + } |
1398 | + {% endfor %} |
1399 | +} |
1400 | diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt |
1401 | new file mode 100644 |
1402 | index 0000000..a0f417d |
1403 | --- /dev/null |
1404 | +++ b/tests/functional/requirements.txt |
1405 | @@ -0,0 +1,2 @@ |
1406 | +git+https://github.com/openstack-charmers/zaza.git#egg=zaza |
1407 | +ops |
1408 | diff --git a/tests/functional/tests/bundles/bionic.yaml b/tests/functional/tests/bundles/bionic.yaml |
1409 | new file mode 100644 |
1410 | index 0000000..e1fd9c0 |
1411 | --- /dev/null |
1412 | +++ b/tests/functional/tests/bundles/bionic.yaml |
1413 | @@ -0,0 +1,11 @@ |
1414 | +series: bionic |
1415 | + |
1416 | +applications: |
1417 | + vpn-strongswan: |
1418 | + charm: ../../../../.build/vpn-strongswan.charm |
1419 | + num_units: 1 |
1420 | + options: |
1421 | + secret: "your secret" |
1422 | + local_addr: "10.0.3.92" |
1423 | + remote_addr: "66.198.198.198" |
1424 | + remote_subnet: "10.191.0.0/24" |
1425 | diff --git a/tests/functional/tests/bundles/focal.yaml b/tests/functional/tests/bundles/focal.yaml |
1426 | new file mode 100644 |
1427 | index 0000000..f9d5a27 |
1428 | --- /dev/null |
1429 | +++ b/tests/functional/tests/bundles/focal.yaml |
1430 | @@ -0,0 +1,11 @@ |
1431 | +series: focal |
1432 | + |
1433 | +applications: |
1434 | + vpn-strongswan: |
1435 | + charm: ../../../../.build/vpn-strongswan.charm |
1436 | + num_units: 1 |
1437 | + options: |
1438 | + secret: "your secret" |
1439 | + local_addr: "10.0.3.92" |
1440 | + remote_addr: "66.198.198.198" |
1441 | + remote_subnet: "10.191.0.0/24" |
1442 | diff --git a/tests/functional/tests/test_vpn_strongswan.py b/tests/functional/tests/test_vpn_strongswan.py |
1443 | new file mode 100644 |
1444 | index 0000000..6c01e58 |
1445 | --- /dev/null |
1446 | +++ b/tests/functional/tests/test_vpn_strongswan.py |
1447 | @@ -0,0 +1,239 @@ |
1448 | +"""Zaza func tests.""" |
1449 | +import concurrent.futures |
1450 | +import unittest |
1451 | + |
1452 | +from ops.model import Network |
1453 | + |
1454 | +from tests import utils |
1455 | + |
1456 | +import zaza.model |
1457 | + |
1458 | + |
1459 | +class BaseStrongSwanTest(unittest.TestCase): |
1460 | + """Base class for strongSwan VPN charm tests.""" |
1461 | + |
1462 | + @classmethod |
1463 | + def setUpClass(cls): |
1464 | + """Run setup for strongSwan VPN tests.""" |
1465 | + cls.model_name = zaza.model.get_juju_model() |
1466 | + cls.application_name = "vpn-strongswan" |
1467 | + |
1468 | + def setUp(self): |
1469 | + """Run setup per test.""" |
1470 | + self._ensure_app_active() |
1471 | + |
1472 | + def _ensure_app_active(self): |
1473 | + """Wait until the Juju application is active.""" |
1474 | + strongswan_workload_checker = utils.get_workload_application_status_checker( |
1475 | + self.application_name, "active" |
1476 | + ) |
1477 | + zaza.model.block_until(strongswan_workload_checker, timeout=15) |
1478 | + |
1479 | + |
1480 | +class StrongSwanConfigValidationTest(BaseStrongSwanTest): |
1481 | + """Tests validating charm correctly validates config data.""" |
1482 | + |
1483 | + @classmethod |
1484 | + def setUpClass(cls): |
1485 | + """Initialize test class.""" |
1486 | + super().setUpClass() |
1487 | + |
1488 | + @utils.config_restore("vpn-strongswan") |
1489 | + def test_required_configs_block_app(self): |
1490 | + """Verify unit gets blocked when no configuration is applied.""" |
1491 | + new_config = dict(local_addr="", remote_addr="", remote_subnet="", secret="") |
1492 | + zaza.model.set_application_config( |
1493 | + self.application_name, new_config, self.model_name |
1494 | + ) |
1495 | + try: |
1496 | + strongswan_workload_checker = utils.get_workload_application_status_checker( |
1497 | + self.application_name, "blocked" |
1498 | + ) |
1499 | + zaza.model.block_until(strongswan_workload_checker, timeout=15) |
1500 | + a_unit = zaza.model.get_units(self.application_name)[0] |
1501 | + self.assertIn("local_addr", a_unit.workload_status_message) |
1502 | + self.assertIn("remote_addr", a_unit.workload_status_message) |
1503 | + self.assertIn("remote_subnet", a_unit.workload_status_message) |
1504 | + self.assertIn("secret", a_unit.workload_status_message) |
1505 | + except concurrent.futures._base.TimeoutError: |
1506 | + self.fail("Failed to enter blocked state when required options not set") |
1507 | + |
1508 | + @utils.config_restore("vpn-strongswan") |
1509 | + def test_out_of_range_debug_level_blocks(self): |
1510 | + """Verify Juju workload status message is applied per config.""" |
1511 | + new_config = dict(debug_level="5") |
1512 | + zaza.model.set_application_config( |
1513 | + self.application_name, new_config, self.model_name |
1514 | + ) |
1515 | + try: |
1516 | + strongswan_workload_checker = utils.get_workload_application_status_checker( |
1517 | + self.application_name, "blocked" |
1518 | + ) |
1519 | + zaza.model.block_until(strongswan_workload_checker, timeout=15) |
1520 | + unit = zaza.model.get_units(self.application_name)[0] |
1521 | + self.assertIn( |
1522 | + "Supported debug levels for charon_debug are between -1 and 4", |
1523 | + unit.workload_status_message, |
1524 | + ) |
1525 | + except concurrent.futures._base.TimeoutError: |
1526 | + self.fail("Failed to enter blocked state when debug_level invalid") |
1527 | + |
1528 | + @utils.config_restore("vpn-strongswan") |
1529 | + def test_invalid_cipher(self): |
1530 | + """Verify unit workload status when a wrong cipher is configured.""" |
1531 | + invalid_cipher = "bad-cipher" |
1532 | + new_config = dict(cipher=invalid_cipher) |
1533 | + zaza.model.set_application_config( |
1534 | + self.application_name, new_config, self.model_name |
1535 | + ) |
1536 | + try: |
1537 | + strongswan_workload_checker = utils.get_workload_application_status_checker( |
1538 | + self.application_name, "blocked" |
1539 | + ) |
1540 | + zaza.model.block_until(strongswan_workload_checker, timeout=15) |
1541 | + unit = zaza.model.get_units(self.application_name)[0] |
1542 | + self.assertIn( |
1543 | + "Unsupported encryption algorithm", unit.workload_status_message |
1544 | + ) |
1545 | + self.assertIn(invalid_cipher, unit.workload_status_message) |
1546 | + except concurrent.futures._base.TimeoutError: |
1547 | + self.fail("Failed to enter blocked state when cipher invalid") |
1548 | + |
1549 | + @utils.config_restore("vpn-strongswan") |
1550 | + def test_invalid_local_addr_ip(self): |
1551 | + """Verify invalid local IPs are properly handled.""" |
1552 | + invalid_ip = "1.1.1.1.1" |
1553 | + new_config = dict(local_addr=invalid_ip) |
1554 | + zaza.model.set_application_config( |
1555 | + self.application_name, new_config, self.model_name |
1556 | + ) |
1557 | + try: |
1558 | + strongswan_workload_checker = utils.get_workload_application_status_checker( |
1559 | + self.application_name, "blocked" |
1560 | + ) |
1561 | + zaza.model.block_until(strongswan_workload_checker, timeout=15) |
1562 | + unit = zaza.model.get_units(self.application_name)[0] |
1563 | + self.assertIn("Invalid IP address", unit.workload_status_message) |
1564 | + self.assertIn(invalid_ip, unit.workload_status_message) |
1565 | + self.assertIn("local_addr", unit.workload_status_message) |
1566 | + except concurrent.futures._base.TimeoutError: |
1567 | + self.fail("Failed to enter blocked state when local_addr IP invalid") |
1568 | + |
1569 | + @utils.config_restore("vpn-strongswan") |
1570 | + def test_invalid_remote_subnet_cidr(self): |
1571 | + """Verify invalid remote CIDRs are properly handled.""" |
1572 | + invalid_cidr = "10.0.0.0/33" |
1573 | + new_config = dict(remote_subnet=invalid_cidr) |
1574 | + zaza.model.set_application_config( |
1575 | + self.application_name, new_config, self.model_name |
1576 | + ) |
1577 | + try: |
1578 | + strongswan_workload_checker = utils.get_workload_application_status_checker( |
1579 | + self.application_name, "blocked" |
1580 | + ) |
1581 | + zaza.model.block_until(strongswan_workload_checker, timeout=15) |
1582 | + unit = zaza.model.get_units(self.application_name)[0] |
1583 | + self.assertIn("Invalid CIDR", unit.workload_status_message) |
1584 | + self.assertIn(invalid_cidr, unit.workload_status_message) |
1585 | + self.assertIn("remote_subnet", unit.workload_status_message) |
1586 | + except concurrent.futures._base.TimeoutError: |
1587 | + self.fail("Failed to enter blocked state when remote_ip CIDR invalid") |
1588 | + |
1589 | + @utils.config_restore("vpn-strongswan") |
1590 | + def test_invalid_secret_blocks(self): |
1591 | + """Verify invalid secrets are properly handled.""" |
1592 | + invalid_secret = ",," |
1593 | + new_config = dict(secret=invalid_secret) |
1594 | + zaza.model.set_application_config( |
1595 | + self.application_name, new_config, self.model_name |
1596 | + ) |
1597 | + try: |
1598 | + strongswan_workload_checker = utils.get_workload_application_status_checker( |
1599 | + self.application_name, "blocked" |
1600 | + ) |
1601 | + zaza.model.block_until(strongswan_workload_checker, timeout=15) |
1602 | + unit = zaza.model.get_units(self.application_name)[0] |
1603 | + self.assertIn("No valid secrets found", unit.workload_status_message) |
1604 | + except concurrent.futures._base.TimeoutError: |
1605 | + self.fail('Failed to enter blocked state when "secret" invalid') |
1606 | + |
1607 | + |
1608 | +class StrongSwanConfigFileTest(BaseStrongSwanTest): |
1609 | + """Test files contain expected configurations.""" |
1610 | + |
1611 | + @classmethod |
1612 | + def setUpClass(cls): |
1613 | + """Initialize test class.""" |
1614 | + super().setUpClass() |
1615 | + |
1616 | + def _get_expected_swanctl_conf(self): |
1617 | + """Generate expected configuration file.""" |
1618 | + a_unit = zaza.model.get_units(self.application_name)[0] |
1619 | + default_network_binding = utils.get_unit_binding_network_config( |
1620 | + a_unit, "default" |
1621 | + ) |
1622 | + network = Network(default_network_binding) |
1623 | + unit_local_subnet = network.interfaces[0].subnet |
1624 | + return utils.generate_swanctl_from_template(unit_local_subnet) |
1625 | + |
1626 | + def test_strongswan_swanctl_config_correct(self): |
1627 | + """Verify config file is correct.""" |
1628 | + expected_swanctl_conf = self._get_expected_swanctl_conf() |
1629 | + zaza.model.block_until_file_has_contents( |
1630 | + self.application_name, |
1631 | + "/etc/swanctl.conf", |
1632 | + expected_swanctl_conf, |
1633 | + timeout=15, |
1634 | + ) |
1635 | + |
1636 | + @utils.config_restore("vpn-strongswan") |
1637 | + def test_strongswan_swanctl_config_correct_after_change(self): |
1638 | + """Verify config file contains the expected secret.""" |
1639 | + new_secret = "new-secret123" |
1640 | + new_config = dict(secret=new_secret) |
1641 | + zaza.model.set_application_config( |
1642 | + self.application_name, new_config, self.model_name |
1643 | + ) |
1644 | + zaza.model.block_until_file_has_contents( |
1645 | + self.application_name, "/etc/swanctl.conf", new_secret, timeout=15 |
1646 | + ) |
1647 | + |
1648 | + def test_strongswan_conf_config_correct_max_debug_val(self): |
1649 | + """Verify Juju config change updates the config file (max value).""" |
1650 | + new_debug_level = "4" |
1651 | + new_config = dict(debug_level=new_debug_level) |
1652 | + expected_strongswan_conf = utils.generate_strongswan_conf_from_template( |
1653 | + new_debug_level |
1654 | + ) |
1655 | + zaza.model.set_application_config( |
1656 | + self.application_name, new_config, self.model_name |
1657 | + ) |
1658 | + zaza.model.block_until_file_has_contents( |
1659 | + self.application_name, |
1660 | + "/etc/strongswan.conf", |
1661 | + expected_strongswan_conf, |
1662 | + timeout=15, |
1663 | + ) |
1664 | + |
1665 | + def test_strongswan_conf_config_correct_min_debug_val(self): |
1666 | + """Verify Juju config change updates the config file (min value).""" |
1667 | + new_debug_level = "-1" |
1668 | + new_config = dict(debug_level=new_debug_level) |
1669 | + expected_strongswan_conf = utils.generate_strongswan_conf_from_template( |
1670 | + new_debug_level |
1671 | + ) |
1672 | + zaza.model.set_application_config( |
1673 | + self.application_name, new_config, self.model_name |
1674 | + ) |
1675 | + zaza.model.block_until_file_has_contents( |
1676 | + self.application_name, |
1677 | + "/etc/strongswan.conf", |
1678 | + expected_strongswan_conf, |
1679 | + timeout=15, |
1680 | + ) |
1681 | + |
1682 | + |
1683 | +class StrongSwanIptablesTest(BaseStrongSwanTest): |
1684 | + """Test iptables changes.""" |
1685 | + |
1686 | + pass |
1687 | diff --git a/tests/functional/tests/tests.yaml b/tests/functional/tests/tests.yaml |
1688 | new file mode 100644 |
1689 | index 0000000..e224fcc |
1690 | --- /dev/null |
1691 | +++ b/tests/functional/tests/tests.yaml |
1692 | @@ -0,0 +1,11 @@ |
1693 | +tests: |
1694 | + - tests.test_vpn_strongswan.StrongSwanConfigValidationTest |
1695 | + - tests.test_vpn_strongswan.StrongSwanConfigFileTest |
1696 | +gate_bundles: |
1697 | + - bionic |
1698 | + - focal |
1699 | +dev_bundles: |
1700 | + - bionic |
1701 | + - focal |
1702 | +smoke_bundles: |
1703 | + - bionic |
1704 | \ No newline at end of file |
1705 | diff --git a/tests/functional/tests/utils.py b/tests/functional/tests/utils.py |
1706 | new file mode 100644 |
1707 | index 0000000..2b913ce |
1708 | --- /dev/null |
1709 | +++ b/tests/functional/tests/utils.py |
1710 | @@ -0,0 +1,182 @@ |
1711 | +"""Zaza func tests utils.""" |
1712 | +import json |
1713 | +import logging |
1714 | +from collections import namedtuple |
1715 | +from functools import wraps |
1716 | + |
1717 | +import zaza.model |
1718 | + |
1719 | +logger = logging.getLogger() |
1720 | + |
1721 | + |
1722 | +def get_app_config(app_name): |
1723 | + """Return app config.""" |
1724 | + return _convert_config(zaza.model.get_application_config(app_name)) |
1725 | + |
1726 | + |
1727 | +def get_workload_application_status_checker(application_name, target_status): |
1728 | + """Return a function for checking the status of all units of an application.""" |
1729 | + # inner function. |
1730 | + async def checker(): |
1731 | + units = await zaza.model.async_get_units(application_name) |
1732 | + unit_statuses_blocked = [ |
1733 | + unit.workload_status == target_status for unit in units |
1734 | + ] |
1735 | + return all(unit_statuses_blocked) |
1736 | + |
1737 | + return checker |
1738 | + |
1739 | + |
1740 | +def config_restore(*applications): |
1741 | + """Return a function to reset application config.""" |
1742 | + |
1743 | + def config_restore_wrap(f): |
1744 | + AppConfigPair = namedtuple("AppConfigPair", ["app_name", "config"]) |
1745 | + |
1746 | + @wraps(f) |
1747 | + def wrapped_f(*args): |
1748 | + original_configs = [ |
1749 | + AppConfigPair(app, get_app_config(app)) for app in applications |
1750 | + ] |
1751 | + try: |
1752 | + f(*args) |
1753 | + finally: |
1754 | + logger.info(original_configs) |
1755 | + for app_config_pair in original_configs: |
1756 | + zaza.model.set_application_config( |
1757 | + app_config_pair.app_name, app_config_pair.config |
1758 | + ) |
1759 | + zaza.model.block_until_all_units_idle(timeout=60) |
1760 | + |
1761 | + return wrapped_f |
1762 | + |
1763 | + return config_restore_wrap |
1764 | + |
1765 | + |
1766 | +def set_config_and_wait(application_name, config, model_name=None): |
1767 | + """Set app config and wait for idle units.""" |
1768 | + zaza.model.set_application_config( |
1769 | + application_name=application_name, configuration=config, model_name=model_name |
1770 | + ) |
1771 | + zaza.model.block_until_all_units_idle() |
1772 | + |
1773 | + |
1774 | +def get_unit_binding_network_config(unit, binding_name): |
1775 | + """Return the network details for a Juju binding.""" |
1776 | + command = "network-get {} --format json".format(binding_name) |
1777 | + network_config_dict = json.loads( |
1778 | + zaza.model.run_on_unit(unit.name, command).get("Stdout", {}) |
1779 | + ) |
1780 | + return network_config_dict |
1781 | + |
1782 | + |
1783 | +def generate_swanctl_from_template(local_subnet): |
1784 | + """Return config file with tweaked local subnet.""" |
1785 | + expected_swanctl_conf = _EXPECTED_SWANCTL_TEMPLATE.replace( |
1786 | + "{local_subnet}", str(local_subnet) |
1787 | + ) |
1788 | + return expected_swanctl_conf |
1789 | + |
1790 | + |
1791 | +def generate_strongswan_conf_from_template(debug_level): |
1792 | + """Return config file with tweaked debug level.""" |
1793 | + expected_strongswan_conf = _EXPECTED_STRONGSWAN_CONF.replace( |
1794 | + "{debug_level}", str(debug_level) |
1795 | + ) |
1796 | + return expected_strongswan_conf |
1797 | + |
1798 | + |
1799 | +def _convert_config(config): |
1800 | + """Convert config dictionary from get_config to one valid for set_config.""" |
1801 | + clean_config = dict() |
1802 | + for key, value in config.items(): |
1803 | + clean_config[key] = "{}".format(value["value"]) |
1804 | + return clean_config |
1805 | + |
1806 | + |
1807 | +_EXPECTED_STRONGSWAN_CONF = """charon { |
1808 | + filelog { |
1809 | + charon { |
1810 | + path = /var/log/ipsec-vpn-strongswan.log |
1811 | + time_format = %b %e %T |
1812 | + ike_name = yes |
1813 | + append = no |
1814 | + default = 0 |
1815 | + flush_line = yes |
1816 | + } |
1817 | + stderr { |
1818 | + app={debug_level} |
1819 | + asn={debug_level} |
1820 | + cfg={debug_level} |
1821 | + chd={debug_level} |
1822 | + dmn={debug_level} |
1823 | + enc={debug_level} |
1824 | + esp={debug_level} |
1825 | + ike={debug_level} |
1826 | + imc={debug_level} |
1827 | + imv={debug_level} |
1828 | + job={debug_level} |
1829 | + knl={debug_level} |
1830 | + lib={debug_level} |
1831 | + mgr={debug_level} |
1832 | + net={debug_level} |
1833 | + pts={debug_level} |
1834 | + tls={debug_level} |
1835 | + tnc={debug_level} |
1836 | + } |
1837 | + } |
1838 | + syslog { |
1839 | + identifier = vpn-strongswan |
1840 | + daemon { |
1841 | + } |
1842 | + auth { |
1843 | + app={debug_level} |
1844 | + asn={debug_level} |
1845 | + cfg={debug_level} |
1846 | + chd={debug_level} |
1847 | + dmn={debug_level} |
1848 | + enc={debug_level} |
1849 | + esp={debug_level} |
1850 | + ike={debug_level} |
1851 | + imc={debug_level} |
1852 | + imv={debug_level} |
1853 | + job={debug_level} |
1854 | + knl={debug_level} |
1855 | + lib={debug_level} |
1856 | + mgr={debug_level} |
1857 | + net={debug_level} |
1858 | + pts={debug_level} |
1859 | + tls={debug_level} |
1860 | + tnc={debug_level} |
1861 | + } |
1862 | + } |
1863 | +}""" |
1864 | + |
1865 | + |
1866 | +_EXPECTED_SWANCTL_TEMPLATE = """connections { |
1867 | + vpn-strongswan { |
1868 | + local_addrs = 10.0.3.92 |
1869 | + remote_addrs = 66.198.198.198 |
1870 | + proposals = aes256gcm16-prfsha384-ecp384 |
1871 | + local-1 { |
1872 | + id = local |
1873 | + } |
1874 | + remote-1 { |
1875 | + id = remote |
1876 | + } |
1877 | + children { |
1878 | + net-net { |
1879 | + local_ts = {local_subnet} |
1880 | + remote_ts = 10.191.0.0/24 |
1881 | + esp_proposals = aes256gcm16-prfsha384-ecp384 |
1882 | + } |
1883 | + } |
1884 | + } |
1885 | +} |
1886 | +secrets { |
1887 | + vpn-strongswan-1 { |
1888 | + id-local = local |
1889 | + id-remote = remote |
1890 | + secret = "your secret" |
1891 | + } |
1892 | +}""" |
1893 | diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt |
1894 | new file mode 100644 |
1895 | index 0000000..dd1cfd0 |
1896 | --- /dev/null |
1897 | +++ b/tests/unit/requirements.txt |
1898 | @@ -0,0 +1,7 @@ |
1899 | +pyyaml |
1900 | +six |
1901 | +jinja2 |
1902 | +coverage |
1903 | +netifaces |
1904 | +python-iptables |
1905 | +numpy |
1906 | diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py |
1907 | new file mode 100644 |
1908 | index 0000000..bc06e7d |
1909 | --- /dev/null |
1910 | +++ b/tests/unit/test_charm.py |
1911 | @@ -0,0 +1,179 @@ |
1912 | +"""Define unit test of strongswan charm.""" |
1913 | +import tempfile |
1914 | +import unittest |
1915 | +from copy import deepcopy |
1916 | +from pathlib import Path |
1917 | +from unittest.mock import patch |
1918 | + |
1919 | +from charm import VpnStrongswanCharm |
1920 | + |
1921 | +from lib_strongswan import StrongSwanServiceError, StrongSwanValidationError |
1922 | + |
1923 | +from ops.model import ActiveStatus, BlockedStatus |
1924 | +from ops.testing import Harness |
1925 | + |
1926 | +import yaml |
1927 | + |
1928 | + |
1929 | +class TestCharm(unittest.TestCase): |
1930 | + """Define TestCharm class.""" |
1931 | + |
1932 | + @classmethod |
1933 | + def setUpClass(cls): |
1934 | + """Set up class fixture.""" |
1935 | + # Setup a tmpdir |
1936 | + cls.tmpdir = tempfile.TemporaryDirectory() |
1937 | + |
1938 | + # Make default config available |
1939 | + with open(Path("./config.yaml"), "r") as config_file: |
1940 | + config = yaml.safe_load(config_file) |
1941 | + cls.charm_config = {} |
1942 | + |
1943 | + for key, _ in config["options"].items(): |
1944 | + cls.charm_config[key] = config["options"][key]["default"] |
1945 | + |
1946 | + additional_config = dict( |
1947 | + secret="mysecret", |
1948 | + local_addr="10.0.0.1", |
1949 | + remote_addr="10.0.1.1", |
1950 | + remote_subnet="10.0.1.0/24", |
1951 | + ) |
1952 | + |
1953 | + cls.charm_config.update(additional_config) |
1954 | + |
1955 | + @classmethod |
1956 | + def tearDownClass(cls): |
1957 | + """Tear down class fixture.""" |
1958 | + cls.tmpdir.cleanup() |
1959 | + |
1960 | + def setUp(self): |
1961 | + """Set up tests.""" |
1962 | + self.harness = Harness(VpnStrongswanCharm) |
1963 | + |
1964 | + def test_initialization(self): |
1965 | + """Verify harness.""" |
1966 | + self.harness.begin() |
1967 | + self.assertFalse(self.harness.charm.state.installed) |
1968 | + self.assertFalse(self.harness.charm.state.configured) |
1969 | + self.assertFalse(self.harness.charm.state.started) |
1970 | + |
1971 | + @patch("charm.VPNProvides") |
1972 | + @patch("charm.NetFilterManager") |
1973 | + @patch("charm.StrongSwanManager") |
1974 | + def test_on_install( |
1975 | + self, mock_strong_swan_manager, mock_net_filter_manager, mock_vpn_provides |
1976 | + ): |
1977 | + """Test install.""" |
1978 | + # TODO: Change this to self.harness.begin_with_initial_hooks() on next |
1979 | + # release of ops (after 0.9.0 release) |
1980 | + self.harness.begin() |
1981 | + self.harness.charm.on.install.emit() |
1982 | + self.assertTrue(self.harness.charm.state.installed) |
1983 | + |
1984 | + @patch("charm.VPNProvides") |
1985 | + @patch("charm.NetFilterManager") |
1986 | + @patch("charm.StrongSwanManager") |
1987 | + def test_on_config_changed_success( |
1988 | + self, mock_strong_swan_manager, mock_net_filter_manager, mock_vpn_provides |
1989 | + ): |
1990 | + """Test config changed.""" |
1991 | + self.harness.begin() |
1992 | + new_secret = "new-secret" |
1993 | + local_config = deepcopy(self.charm_config) |
1994 | + local_config["secret"] = new_secret |
1995 | + self.harness.update_config(local_config) |
1996 | + self.assertTrue(self.harness.charm.state.configured) |
1997 | + self.assertIsInstance(self.harness.charm.unit.status, ActiveStatus) |
1998 | + |
1999 | + @patch("charm.VPNProvides") |
2000 | + @patch("charm.NetFilterManager") |
2001 | + @patch("charm.StrongSwanManager") |
2002 | + def test_on_config_changed_missing_secret( |
2003 | + self, mock_strong_swan_manager, mock_net_filter_manager, mock_vpn_provides |
2004 | + ): |
2005 | + """Test config changed.""" |
2006 | + self.harness.begin() |
2007 | + new_bad_secret = "" |
2008 | + local_config = deepcopy(self.charm_config) |
2009 | + local_config.update(dict(secret=new_bad_secret)) |
2010 | + self.harness.update_config(local_config) |
2011 | + self.assertFalse(self.harness.charm.state.configured) |
2012 | + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) |
2013 | + self.assertIn("secret", self.harness.charm.unit.status.message) |
2014 | + |
2015 | + @patch("charm.VPNProvides") |
2016 | + @patch("charm.NetFilterManager") |
2017 | + @patch("charm.StrongSwanManager") |
2018 | + def test_on_config_changed_enable_fails( |
2019 | + self, mock_strong_swan_manager, mock_net_filter_manager, mock_vpn_provides |
2020 | + ): |
2021 | + """Test config changed fails when exception raised on enable fail.""" |
2022 | + self.harness.begin() |
2023 | + expected_workload_status = "expected message" |
2024 | + mock_strong_swan_manager.return_value.enable_strongswan.side_effect = ( |
2025 | + StrongSwanServiceError(workload_status=expected_workload_status) |
2026 | + ) |
2027 | + self.harness.update_config(self.charm_config) |
2028 | + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) |
2029 | + self.assertEqual( |
2030 | + self.harness.charm.unit.status.message, expected_workload_status |
2031 | + ) |
2032 | + |
2033 | + @patch("charm.VPNProvides") |
2034 | + @patch("charm.NetFilterManager") |
2035 | + @patch("charm.StrongSwanManager") |
2036 | + def test_on_config_changed_restart_fails( |
2037 | + self, mock_strong_swan_manager, mock_net_filter_manager, mock_vpn_provides |
2038 | + ): |
2039 | + """Test config changed fails when exception raised on restart fail.""" |
2040 | + self.harness.begin() |
2041 | + expected_workload_status = "expected message" |
2042 | + mock_strong_swan_manager.return_value.restart_strongswan.side_effect = ( |
2043 | + StrongSwanServiceError(workload_status=expected_workload_status) |
2044 | + ) |
2045 | + self.harness.update_config(self.charm_config) |
2046 | + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) |
2047 | + self.assertEqual( |
2048 | + self.harness.charm.unit.status.message, expected_workload_status |
2049 | + ) |
2050 | + |
2051 | + @patch("charm.VPNProvides") |
2052 | + @patch("charm.NetFilterManager") |
2053 | + @patch("charm.StrongSwanManager") |
2054 | + def test_on_config_changed_generate_config_fails( |
2055 | + self, mock_strong_swan_manager, mock_net_filter_manager, mock_vpn_provides |
2056 | + ): |
2057 | + """Test config changed fails when exception raised on generate_config fail.""" |
2058 | + self.harness.begin() |
2059 | + expected_workload_status = "expected message" |
2060 | + mock_strong_swan_manager.return_value.generate_strongswan_config.side_effect = ( |
2061 | + StrongSwanValidationError(workload_status=expected_workload_status) |
2062 | + ) |
2063 | + self.harness.update_config(self.charm_config) |
2064 | + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) |
2065 | + self.assertEqual( |
2066 | + self.harness.charm.unit.status.message, expected_workload_status |
2067 | + ) |
2068 | + |
2069 | + @patch("charm.VPNProvides") |
2070 | + @patch("charm.NetFilterManager") |
2071 | + @patch("charm.StrongSwanManager") |
2072 | + def test_install_fails( |
2073 | + self, mock_strong_swan_manager, mock_net_filter_manager, mock_vpn_provides |
2074 | + ): |
2075 | + """Test installation failure.""" |
2076 | + self.harness.begin() |
2077 | + expected_workload_status = "expected message" |
2078 | + mock_strong_swan_manager.return_value.install_strongswan.side_effect = ( |
2079 | + StrongSwanServiceError(workload_status=expected_workload_status) |
2080 | + ) |
2081 | + self.harness.charm.on.install.emit() |
2082 | + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) |
2083 | + self.assertFalse(self.harness.charm.state.installed) |
2084 | + self.assertEqual( |
2085 | + self.harness.charm.unit.status.message, expected_workload_status |
2086 | + ) |
2087 | + |
2088 | + |
2089 | +if __name__ == "__main__": |
2090 | + unittest.main() |
2091 | diff --git a/tests/unit/test_lib_strongswan.py b/tests/unit/test_lib_strongswan.py |
2092 | new file mode 100644 |
2093 | index 0000000..574c713 |
2094 | --- /dev/null |
2095 | +++ b/tests/unit/test_lib_strongswan.py |
2096 | @@ -0,0 +1,260 @@ |
2097 | +"""Define unit test of strongswan class.""" |
2098 | +import unittest |
2099 | +from ipaddress import IPv4Network |
2100 | +from subprocess import CalledProcessError |
2101 | +from unittest.mock import Mock, mock_open, patch |
2102 | + |
2103 | +from lib.lib_strongswan import ( |
2104 | + StrongSwanManager, |
2105 | + StrongSwanServiceError, |
2106 | + StrongSwanValidationError, |
2107 | + file_get_contents, |
2108 | + get_default_route_subnet, |
2109 | +) |
2110 | + |
2111 | + |
2112 | +class TestStrongswanManager(unittest.TestCase): |
2113 | + """Define TestStrongswanManager class.""" |
2114 | + |
2115 | + @classmethod |
2116 | + def setUpClass(cls): |
2117 | + """Set up class fixture.""" |
2118 | + # Load default config |
2119 | + cls.unit = Mock() |
2120 | + cls.relations = Mock() |
2121 | + |
2122 | + @patch("lib.lib_strongswan.distro.codename") |
2123 | + def setUp(self, mock_codename): |
2124 | + """Set up test fixture.""" |
2125 | + mock_codename.return_value = "bionic" |
2126 | + self.strongswan_manager = StrongSwanManager(self.unit) |
2127 | + self.strongswan_manager.installed = False |
2128 | + self.strongswan_manager.configured = False |
2129 | + self.strongswan_manager.started = False |
2130 | + basic_table = {"PREROUTING": {}, "INPUT": {}, "POSTROUTING": {}, "OUTPUT": {}} |
2131 | + self.strongswan_manager.dump_table = Mock(return_value=basic_table) |
2132 | + self.strongswan_manager.unit.model.config = {"debug": 0} |
2133 | + |
2134 | + def tearDown(self): |
2135 | + """Clean up test fixture.""" |
2136 | + self.strongswan_manager.unit.model.config = {"debug": 0} |
2137 | + |
2138 | + @patch("lib.lib_strongswan.os.path.exists") |
2139 | + @patch("lib.lib_strongswan.subprocess.check_output") |
2140 | + @patch("lib.lib_strongswan.is_string_in_file") |
2141 | + def test_modify_app_armor_profile_requires_modify( |
2142 | + self, mock_string_in_file, mock_check_output, mock_exists |
2143 | + ): |
2144 | + """Test apparmor profile not exists.""" |
2145 | + mock_exists.return_value = True |
2146 | + mock_string_in_file.return_value = False |
2147 | + self.strongswan_manager.modify_app_armor_profile() |
2148 | + self.assertEqual( |
2149 | + mock_check_output.call_count, |
2150 | + 4, |
2151 | + "Subprocess call was not invoked the correct number of times.", |
2152 | + ) |
2153 | + |
2154 | + @patch("lib.lib_strongswan.distro.codename") |
2155 | + def test_strongswan_service_is_focal(self, mock_codename): |
2156 | + """Test strongSwan service is correct when distro is focal.""" |
2157 | + expected_distro = "focal" |
2158 | + mock_codename.return_value = expected_distro |
2159 | + self.assertEqual( |
2160 | + self.strongswan_manager.strongswan_service, "strongswan.service" |
2161 | + ) |
2162 | + |
2163 | + @patch("lib.lib_strongswan.distro") |
2164 | + def test_strongswan_service_is_not_focal(self, mock_codename): |
2165 | + """Test strongSwan service is correct when distro is not focal.""" |
2166 | + expected_distro = "bionic" |
2167 | + mock_codename.return_value = expected_distro |
2168 | + self.assertEqual( |
2169 | + self.strongswan_manager.strongswan_service, "strongswan-swanctl.service" |
2170 | + ) |
2171 | + |
2172 | + @patch("lib.lib_strongswan.os.path.exists") |
2173 | + @patch("lib.lib_strongswan.subprocess.check_output") |
2174 | + def test_modify_app_armor_profile_doesnt_exist(self, mock_check_output, exists): |
2175 | + """Test apparmor profile.""" |
2176 | + exists.return_value = False # change to true for remainder of test |
2177 | + self.strongswan_manager.modify_app_armor_profile() |
2178 | + mock_check_output.assert_not_called() |
2179 | + |
2180 | + @patch("lib.lib_strongswan.apt_install") |
2181 | + @patch("lib.lib_strongswan.os.path.exists") |
2182 | + def test_install_strongswan_requires_install(self, mock_exists, mock_apt_install): |
2183 | + """Test the installation of strongswan.""" |
2184 | + mock_exists.return_value = False |
2185 | + self.strongswan_manager.install_strongswan() |
2186 | + mock_apt_install.assert_called_once() |
2187 | + |
2188 | + @patch("lib.lib_strongswan.apt_install") |
2189 | + @patch("lib.lib_strongswan.os.path.exists") |
2190 | + def test_install_strongswan_already_installed(self, mock_exists, mock_apt_install): |
2191 | + """Test the installation of strongswan.""" |
2192 | + mock_exists.return_value = True |
2193 | + is_success = self.strongswan_manager.install_strongswan() |
2194 | + mock_apt_install.assert_not_called() |
2195 | + self.assertTrue(is_success) |
2196 | + |
2197 | + @patch("lib.lib_strongswan.apt_install") |
2198 | + @patch("lib.lib_strongswan.os.path.exists") |
2199 | + def test_install_strongswan_install_fails(self, mock_exists, mock_apt_install): |
2200 | + """Test strongswan installation failure returns correct exception.""" |
2201 | + mock_exists.return_value = False |
2202 | + mock_apt_install.side_effect = CalledProcessError(cmd="", returncode=1) |
2203 | + try: |
2204 | + self.strongswan_manager.install_strongswan() |
2205 | + except StrongSwanServiceError: |
2206 | + mock_apt_install.assert_called_once() |
2207 | + else: |
2208 | + self.fail("install_strongswan should have raised an error") |
2209 | + |
2210 | + @patch("lib.lib_strongswan.subprocess.check_output") |
2211 | + def test_enable_strongswan(self, mock_check_output): |
2212 | + """Test enabling the strongswan service.""" |
2213 | + self.strongswan_manager.enable_strongswan() |
2214 | + mock_check_output.assert_called_once() |
2215 | + |
2216 | + @patch("lib.lib_strongswan.subprocess.check_output") |
2217 | + def test_enable_strongswan_cmd_error(self, mock_check_output): |
2218 | + """Test enabling the strongswan service, where check_output errors.""" |
2219 | + mock_check_output.side_effect = CalledProcessError(cmd="", returncode=1) |
2220 | + try: |
2221 | + self.strongswan_manager.enable_strongswan() |
2222 | + mock_check_output.assert_called_once() |
2223 | + except StrongSwanServiceError as e: |
2224 | + self.assertIn(self.strongswan_manager.strongswan_service, e.workload_status) |
2225 | + else: |
2226 | + self.fail("enable_strongswan should have raised a StrongSwanServiceError") |
2227 | + |
2228 | + @patch("lib.lib_strongswan.subprocess.check_output") |
2229 | + def test_restart_strongswan_is_required(self, mock_check_output): |
2230 | + """Test restarting the strongswan service.""" |
2231 | + self.strongswan_manager.swanctl_restart_required = True |
2232 | + self.strongswan_manager.restart_strongswan() |
2233 | + mock_check_output.assert_called_once() |
2234 | + self.assertFalse(self.strongswan_manager.swanctl_restart_required) |
2235 | + |
2236 | + @patch("lib.lib_strongswan.subprocess.check_output") |
2237 | + def test_restart_strongswan_not_required(self, mock_check_output): |
2238 | + """Test restarting the strongswan service.""" |
2239 | + self.strongswan_manager.swanctl_restart_required = False |
2240 | + self.strongswan_manager.restart_strongswan() |
2241 | + mock_check_output.assert_not_called() |
2242 | + self.assertFalse(self.strongswan_manager.swanctl_restart_required) |
2243 | + |
2244 | + @patch("lib.lib_strongswan.subprocess.check_output") |
2245 | + def test_restart_strongswan_is_required_fails(self, mock_check_output): |
2246 | + """Test restarting the strongswan service.""" |
2247 | + self.strongswan_manager.swanctl_restart_required = True |
2248 | + mock_check_output.side_effect = CalledProcessError(cmd="", returncode=1) |
2249 | + try: |
2250 | + self.strongswan_manager.restart_strongswan() |
2251 | + except StrongSwanServiceError as e: |
2252 | + mock_check_output.assert_called_once() |
2253 | + self.assertTrue(self.strongswan_manager.swanctl_restart_required) |
2254 | + self.assertIn(self.strongswan_manager.strongswan_service, e.workload_status) |
2255 | + else: |
2256 | + self.fail("restart_strongswan should have raised StrongSwanServiceError") |
2257 | + |
2258 | + def test_validate_proposals_one_cipher_success(self): |
2259 | + """Test proposals validation.""" |
2260 | + self.strongswan_manager.unit.model.config = {"cipher": "aes256-sha256-modp2048"} |
2261 | + self.assertEqual( |
2262 | + self.strongswan_manager.validate_proposals("cipher"), |
2263 | + "aes256-sha256-modp2048", |
2264 | + ) |
2265 | + |
2266 | + def test_validate_charon_debug_integer_out_of_range(self): |
2267 | + """Verify a workload status (error message) is returned.""" |
2268 | + self.strongswan_manager.unit.model.config = {"debug_level": 5} |
2269 | + try: |
2270 | + self.strongswan_manager.validate_charon_debug("debug_level") |
2271 | + except StrongSwanValidationError as e: |
2272 | + self.assertIn("5", e.workload_status) |
2273 | + else: |
2274 | + self.fail( |
2275 | + "StrongSwanValidationError should have been thrown for out of" |
2276 | + " range int." |
2277 | + ) |
2278 | + |
2279 | + def test_validate_charon_debug_success(self): |
2280 | + """Verify a valid debug level is allowed.""" |
2281 | + expected_debug_level = 2 |
2282 | + self.strongswan_manager.unit.model.config = dict( |
2283 | + debug_level=expected_debug_level |
2284 | + ) |
2285 | + actual_debug_level_map = self.strongswan_manager.validate_charon_debug( |
2286 | + "debug_level" |
2287 | + ) |
2288 | + for charon_key, actual_debug_level in actual_debug_level_map.items(): |
2289 | + self.assertEqual( |
2290 | + actual_debug_level, |
2291 | + expected_debug_level, |
2292 | + "debug level for {} is incorrect".format(charon_key), |
2293 | + ) |
2294 | + |
2295 | + def test_validate_ip(self): |
2296 | + """Test validate ip.""" |
2297 | + self.strongswan_manager.unit.model.config = {"local_addr": "10.10.10.10"} |
2298 | + self.assertEqual( |
2299 | + self.strongswan_manager.validate_ip("local_addr"), "10.10.10.10" |
2300 | + ) |
2301 | + |
2302 | + def test_validate_cidr(self): |
2303 | + """Test validate cidr.""" |
2304 | + self.strongswan_manager.unit.model.config = {"remote_subnet": "10.10.10.0/24"} |
2305 | + self.assertEqual( |
2306 | + self.strongswan_manager.validate_cidr("remote_subnet"), "10.10.10.0/24" |
2307 | + ) |
2308 | + |
2309 | + def test_validate_secrets(self): |
2310 | + """Test validate secrets.""" |
2311 | + self.strongswan_manager.unit.model.config = {"secrets": "ssss"} |
2312 | + self.assertEqual(self.strongswan_manager.validate_secrets("secrets"), ["ssss"]) |
2313 | + |
2314 | + @patch("lib.lib_strongswan.get_default_route_subnet") |
2315 | + def test_generate_jinja_context_success(self, mock_get_default_route_subnet): |
2316 | + """Test generate jinja template context.""" |
2317 | + expected_network = IPv4Network("192.168.0.0/24") |
2318 | + self.strongswan_manager.unit.model.app.name = "test" |
2319 | + mock_get_default_route_subnet.return_value = expected_network |
2320 | + actual_jinja_context = self.strongswan_manager.generate_jinja_context() |
2321 | + self.assertEqual(actual_jinja_context["charm_name"], "test") |
2322 | + self.assertEqual(actual_jinja_context["local_subnet"], expected_network) |
2323 | + |
2324 | + # @patch('lib.lib_strongswan.get_default_route_subnet') |
2325 | + # def test_generate_jinja_context_success(self, mock_get_default_route_subnet): |
2326 | + # """Test generate jinja template context.""" |
2327 | + # expected_network = IPv4Network('192.168.0.0/24') |
2328 | + # self.strongswan_manager.unit.model.app.name = "test" |
2329 | + # mock_get_default_route_subnet.return_value = expected_network |
2330 | + # actual_jinja_context = self.strongswan_manager.generate_jinja_context() |
2331 | + # self.assertEqual(actual_jinja_context['charm_name'], 'test') |
2332 | + # self.assertEqual(actual_jinja_context['local_subnet'], expected_network) |
2333 | + |
2334 | + @patch("lib.lib_strongswan.os.utime") |
2335 | + @patch("lib.lib_strongswan.Environment.get_template") |
2336 | + def test_generate_strongswan_config(self, template_file, utime): |
2337 | + """Test rendering config with no relation information.""" |
2338 | + with patch("builtins.open", mock_open(read_data="data")) as mock_file: |
2339 | + assert open("/etc/swanctl.conf").read() == "data" |
2340 | + mock_file.assert_called_with("/etc/swanctl.conf") |
2341 | + self.assertEqual(self.strongswan_manager.generate_strongswan_config(), None) |
2342 | + |
2343 | + |
2344 | +def test_get_default_route_subnet(self): |
2345 | + """Test get default route.""" |
2346 | + self.assertEqual(type(get_default_route_subnet()), IPv4Network) |
2347 | + |
2348 | + |
2349 | +@patch("lib.lib_strongswan.os.utime") |
2350 | +def test_file_get_contents(self, utime): |
2351 | + """Test get file contents.""" |
2352 | + with patch("builtins.open", mock_open(read_data="data")) as mock_file: |
2353 | + assert open("/etc/swanctl.conf").read() == "data" |
2354 | + mock_file.assert_called_with("/etc/swanctl.conf") |
2355 | + |
2356 | + self.assertEqual(file_get_contents("/etc/swanctl.conf"), b"") |
2357 | diff --git a/tox.ini b/tox.ini |
2358 | new file mode 100644 |
2359 | index 0000000..adefd9d |
2360 | --- /dev/null |
2361 | +++ b/tox.ini |
2362 | @@ -0,0 +1,79 @@ |
2363 | +[tox] |
2364 | +skipsdist=True |
2365 | +skip_missing_interpreters = True |
2366 | +envlist = lint, unit, func |
2367 | + |
2368 | +[testenv] |
2369 | +basepython = python3 |
2370 | +setenv = |
2371 | + PYTHONPATH = {toxinidir}:{toxinidir}/lib/:{toxinidir}/hooks/:{toxinidir}/src/ |
2372 | +passenv = |
2373 | + HOME |
2374 | + PATH |
2375 | + CHARM_BUILD_DIR |
2376 | + PYTEST_KEEP_MODEL |
2377 | + PYTEST_CLOUD_NAME |
2378 | + PYTEST_CLOUD_REGION |
2379 | + PYTEST_MODEL |
2380 | + MODEL_SETTINGS |
2381 | + HTTP_PROXY |
2382 | + HTTPS_PROXY |
2383 | + NO_PROXY |
2384 | + SNAP_HTTP_PROXY |
2385 | + SNAP_HTTPS_PROXY |
2386 | + |
2387 | +[testenv:build] |
2388 | +deps = charmcraft |
2389 | +commands = charmcraft build |
2390 | + |
2391 | +[testenv:lint] |
2392 | +commands = |
2393 | + flake8 |
2394 | + black --check --exclude "/(\.eggs|\.git|\.tox|\.venv|\.build|dist|charmhelpers|mod|build|vpncommon)/" . |
2395 | +deps = |
2396 | + black |
2397 | + flake8 |
2398 | + flake8-docstrings |
2399 | + flake8-import-order |
2400 | + pep8-naming |
2401 | + flake8-colors |
2402 | + |
2403 | +[flake8] |
2404 | +exclude = |
2405 | + .git, |
2406 | + __pycache__, |
2407 | + .tox, |
2408 | + charmhelpers, |
2409 | + mod, |
2410 | + build, |
2411 | + .build |
2412 | + |
2413 | +max-line-length = 88 |
2414 | +max-complexity = 10 |
2415 | + |
2416 | +[testenv:black] |
2417 | +commands = |
2418 | + black --exclude "/(\.eggs|\.git|\.tox|\.venv|\.build|dist|charmhelpers|mod|vpncommon)/" . |
2419 | +deps = |
2420 | + black |
2421 | + |
2422 | +[testenv:unit] |
2423 | +commands = |
2424 | + coverage run -m unittest discover -s {toxinidir}/tests/unit -v |
2425 | + coverage report --omit tests/*,mod/*,.tox/*,lib/vpncommon/* |
2426 | + coverage html --omit tests/*,mod/*,.tox/*,lib/vpncommon/* |
2427 | +deps = -r{toxinidir}/tests/unit/requirements.txt |
2428 | + -r{toxinidir}/requirements.txt |
2429 | + |
2430 | +[testenv:func] |
2431 | +changedir = {toxinidir}/tests/functional |
2432 | +commands = functest-run-suite {posargs} |
2433 | +deps = -r{toxinidir}/tests/functional/requirements.txt |
2434 | + |
2435 | +[testenv:func-noop] |
2436 | +basepython = python3 |
2437 | +commands = |
2438 | + functest-run-suite --help |
2439 | +deps = -r{toxinidir}/tests/functional/requirements.txt |
2440 | + -r{toxinidir}/requirements.txt |
2441 | + -r{toxinidir}/tests/unit/requirements.txt |
some small inline comments added