Merge charm-vpn-strongswan:strongswan-dev into charm-vpn-strongswan:master

Proposed by David O Neill
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)
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
To post a comment you must log in.
Revision history for this message
Diko Parvanov (dparv) wrote :

some small inline comments added

review: Needs Fixing
Revision history for this message
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.

review: Approve
Revision history for this message
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.

Revision history for this message
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

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.gitignore b/.gitignore
2new file mode 100644
3index 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
49diff --git a/.gitmodules b/.gitmodules
50new file mode 100644
51index 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
59diff --git a/Makefile b/Makefile
60new file mode 100644
61index 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
146diff --git a/README.md b/README.md
147new file mode 100644
148index 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)
174diff --git a/config.yaml b/config.yaml
175new file mode 100644
176index 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.
238diff --git a/copyright b/copyright
239new file mode 100644
240index 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
261diff --git a/hooks/.empty b/hooks/.empty
262new file mode 100644
263index 0000000..e69de29
264--- /dev/null
265+++ b/hooks/.empty
266diff --git a/icon.svg b/icon.svg
267new file mode 100644
268index 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>
581diff --git a/lib/__init__.py b/lib/__init__.py
582new file mode 100644
583index 0000000..7106537
584--- /dev/null
585+++ b/lib/__init__.py
586@@ -0,0 +1 @@
587+"""Lib module."""
588diff --git a/lib/lib_strongswan.py b/lib/lib_strongswan.py
589new file mode 100644
590index 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")
1020diff --git a/lib/strongswan_constants.py b/lib/strongswan_constants.py
1021new file mode 100644
1022index 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+}
1075diff --git a/lib/vpncommon b/lib/vpncommon
1076new file mode 120000
1077index 0000000..8555a68
1078--- /dev/null
1079+++ b/lib/vpncommon
1080@@ -0,0 +1 @@
1081+../mod/vpncommon/
1082\ No newline at end of file
1083diff --git a/metadata.yaml b/metadata.yaml
1084new file mode 100644
1085index 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:
1111diff --git a/mod/vpncommon b/mod/vpncommon
1112new file mode 160000
1113index 0000000..9a74b6b
1114--- /dev/null
1115+++ b/mod/vpncommon
1116@@ -0,0 +1 @@
1117+Subproject commit 9a74b6b8176f81c0bfc46c7d63b26e6a00e55b3a
1118diff --git a/requirements.txt b/requirements.txt
1119new file mode 100644
1120index 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
1129diff --git a/src/charm.py b/src/charm.py
1130new file mode 100755
1131index 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)
1303diff --git a/templates/strongswan.conf.j2 b/templates/strongswan.conf.j2
1304new file mode 100644
1305index 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
1337diff --git a/templates/swanctl.conf.j2 b/templates/swanctl.conf.j2
1338new file mode 100644
1339index 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+}
1400diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt
1401new file mode 100644
1402index 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
1408diff --git a/tests/functional/tests/bundles/bionic.yaml b/tests/functional/tests/bundles/bionic.yaml
1409new file mode 100644
1410index 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"
1425diff --git a/tests/functional/tests/bundles/focal.yaml b/tests/functional/tests/bundles/focal.yaml
1426new file mode 100644
1427index 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"
1442diff --git a/tests/functional/tests/test_vpn_strongswan.py b/tests/functional/tests/test_vpn_strongswan.py
1443new file mode 100644
1444index 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
1687diff --git a/tests/functional/tests/tests.yaml b/tests/functional/tests/tests.yaml
1688new file mode 100644
1689index 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
1705diff --git a/tests/functional/tests/utils.py b/tests/functional/tests/utils.py
1706new file mode 100644
1707index 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+}"""
1893diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
1894new file mode 100644
1895index 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
1906diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py
1907new file mode 100644
1908index 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()
2091diff --git a/tests/unit/test_lib_strongswan.py b/tests/unit/test_lib_strongswan.py
2092new file mode 100644
2093index 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"")
2357diff --git a/tox.ini b/tox.ini
2358new file mode 100644
2359index 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

Subscribers

People subscribed via source and target branches