-
-
Notifications
You must be signed in to change notification settings - Fork 342
319 lines (280 loc) · 11.9 KB
/
Copy pathci.yml
File metadata and controls
319 lines (280 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
name: CI
# This workflow tests the stack. It runs on each push and on each pull
# request. It also runs one time each week. The weekly run finds a problem
# in a new image version.
on:
push:
branches: [master, main]
pull_request:
schedule:
# Run at 06:00 UTC on Monday. This run replaces the pinned image
# versions with "latest", so it finds a new image version that breaks
# the stack before a user does. A red weekly run means: read the
# release notes of the images, not: the repository is broken.
- cron: '0 6 * * 1'
workflow_dispatch:
permissions:
contents: read
# A new push cancels the runs of the old push.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
lint:
name: Check the files
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Check the syntax of the shell scripts
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
shellcheck scripts/*.sh tests/*.sh
- name: Run the setup script regressions
run: ./tests/script-tests.sh
- name: Check the Windows test syntax
shell: pwsh
run: |
$tokens = $null
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path tests/windows-client-test.ps1),
[ref]$tokens,
[ref]$errors
) | Out-Null
if ($errors.Count -ne 0) {
$errors | ForEach-Object { Write-Error $_.Message }
exit 1
}
- name: Check that the compose file is valid
run: |
cp .env.example .env
# Give a value to each necessary variable. The compose file stops
# without these values.
sed -i 's|^VPN_HOST=.*|VPN_HOST=192.0.2.1|' .env
sed -i 's|^PIHOLE_PASSWORD=.*|PIHOLE_PASSWORD=ci-test-password|' .env
sed -i 's|^WG_EASY_PASSWORD=.*|WG_EASY_PASSWORD=ci-test-password|' .env
docker compose config --quiet
COMPOSE_PROFILES=wireguard docker compose config --quiet
- name: Check that a missing password stops the stack
run: |
printf 'VPN_HOST=192.0.2.1\n' > empty.env
if docker compose --env-file empty.env config --quiet 2> error.log; then
echo "ERROR: The compose file started without a password."
exit 1
fi
# Compose names one missing variable in the error. Which one comes
# first depends on the compose version, so accept either password.
grep -qE 'PIHOLE_PASSWORD|WG_EASY_PASSWORD' error.log || {
echo "ERROR: The refusal did not mention a password variable."
cat error.log
exit 1
}
echo "OK: The compose file needs a password."
- name: Check that no secret is in the repository
run: |
# The file .env holds the passwords. Git must ignore it.
git check-ignore .env > /dev/null || {
echo "ERROR: Git does not ignore the file .env."
exit 1
}
# Ask about a file inside the directory, not about the directory
# itself. The pattern "data/" only matches a directory, and on a
# fresh checkout the directory does not exist yet, so the plain
# "data" question gets the answer "not ignored".
git check-ignore data/anything > /dev/null || {
echo "ERROR: Git does not ignore the directory data."
exit 1
}
echo "OK: Git ignores the secret files."
test:
name: Start the stack and test the DNS
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
profile: [wg-easy, wireguard]
steps:
- uses: actions/checkout@v4
- name: Make the configuration file
run: |
cp .env.example .env
sed -i 's|^VPN_HOST=.*|VPN_HOST=192.0.2.1|' .env
sed -i 's|^PIHOLE_PASSWORD=.*|PIHOLE_PASSWORD=ci-test-password|' .env
sed -i 's|^WG_EASY_PASSWORD=.*|WG_EASY_PASSWORD=ci-test-password|' .env
sed -i 's|^COMPOSE_PROFILES=.*|COMPOSE_PROFILES=${{ matrix.profile }}|' .env
- name: Use the newest images on the weekly run
if: github.event_name == 'schedule'
run: |
# The normal runs test the pinned versions, the same bits every
# time. The weekly run tests the newest images instead, so a new
# image version that breaks the stack shows up here first.
sed -i -E 's|^(PIHOLE_VERSION)=.*|\1=latest|' .env
sed -i -E 's|^(UNBOUND_VERSION)=.*|\1=latest|' .env
sed -i -E 's|^(WG_EASY_VERSION)=.*|\1=15|' .env
sed -i -E 's|^(WIREGUARD_VERSION)=.*|\1=latest|' .env
grep -E '^(PIHOLE|UNBOUND|WG_EASY|WIREGUARD)_VERSION=' .env
- name: Start the stack
run: docker compose up -d --wait --wait-timeout 180
- name: Show the state of the containers
if: always()
run: docker compose ps
- name: Test the name resolution through Pi-hole
run: |
for i in $(seq 1 10); do
result=$(docker exec wirehole-pihole dig +short +time=5 example.com @127.0.0.1 || true)
if [ -n "$result" ]; then
echo "OK: Pi-hole resolved example.com to $result"
exit 0
fi
echo "Attempt $i gave no answer. The test waits 5 seconds."
sleep 5
done
echo "ERROR: Pi-hole did not resolve the name."
exit 1
- name: Test the recursive resolver
run: |
result=$(docker exec wirehole-pihole dig +short +time=8 wikipedia.org @10.2.0.200)
test -n "$result"
echo "OK: Unbound resolved wikipedia.org to $result"
- name: Test the DNSSEC validation
run: |
# A signed domain must give the flag "ad".
flags=$(docker exec wirehole-pihole dig +dnssec +time=8 cloudflare.com @10.2.0.200 | grep '^;; flags')
echo "$flags" | grep -q ' ad' || {
echo "ERROR: Unbound did not validate a signed domain."
echo "$flags"
exit 1
}
echo "OK: Unbound validated a signed domain."
# A domain with a bad signature must give SERVFAIL.
status=$(docker exec wirehole-pihole dig +time=8 dnssec-failed.org @10.2.0.200 | grep 'status:')
echo "$status" | grep -q 'SERVFAIL' || {
echo "ERROR: Unbound accepted a bad signature."
echo "$status"
exit 1
}
echo "OK: Unbound refused a bad signature."
- name: Test the advertisement blocking
run: |
result=$(docker exec wirehole-pihole dig +short +time=5 doubleclick.net @127.0.0.1)
test "$result" = "0.0.0.0"
echo "OK: Pi-hole blocked doubleclick.net"
- name: Test the web interfaces
run: |
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:8080/admin/)
echo "Pi-hole answered with HTTP $code"
test "$code" -lt 500
if [ "${{ matrix.profile }}" = "wg-easy" ]; then
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:51821/)
echo "wg-easy answered with HTTP $code"
test "$code" -lt 500
fi
- name: Test that port 53 is not public
run: |
# The stack must not publish the DNS port. An open resolver is
# dangerous.
if docker compose ps --format '{{.Ports}}' | grep -qE '0\.0\.0\.0:53|:::53'; then
echo "ERROR: The stack publishes port 53 to all addresses."
docker compose ps --format '{{.Name}}: {{.Ports}}'
exit 1
fi
echo "OK: The stack does not publish port 53."
- name: Test that the VPN interface works
run: |
if [ "${{ matrix.profile }}" = "wg-easy" ]; then
docker exec wirehole-wg-easy wg show | grep -q 'interface:'
else
docker exec wirehole-wireguard wg show | grep -q 'interface:'
# The service must write a configuration file for the client.
# It writes the file a moment after the start, so wait for it.
# Read the file in the container. The files on the host belong
# to the user of the variable PUID.
for i in $(seq 1 30); do
if docker exec wirehole-wireguard cat /config/peer1/peer1.conf 2>/dev/null \
| grep -q '^DNS = 10.2.0.100'; then
break
fi
if [ "$i" = "30" ]; then
echo "ERROR: No peer configuration appeared after 60 seconds."
docker exec wirehole-wireguard ls -R /config | head -30
exit 1
fi
sleep 2
done
fi
echo "OK: The VPN interface works."
- name: Read the logs after a failure
if: failure()
run: docker compose logs --tail 100
- name: Stop the stack
if: always()
run: docker compose --profile wg-easy --profile wireguard down -v
ui:
name: Drive the web interfaces in a browser
runs-on: ubuntu-latest
timeout-minutes: 30
# The e2e job proves the VPN and the API. This job proves the pages: a
# browser fills the login forms, reads the dashboard, creates a client,
# and downloads its configuration. A page that throws a JavaScript
# error fails the job.
steps:
- uses: actions/checkout@v4
- name: Make the configuration file
run: |
cp .env.example .env
sed -i 's|^VPN_HOST=.*|VPN_HOST=192.0.2.1|' .env
sed -i 's|^PIHOLE_PASSWORD=.*|PIHOLE_PASSWORD=ci-test-password|' .env
sed -i 's|^WG_EASY_PASSWORD=.*|WG_EASY_PASSWORD=ci-test-password|' .env
- name: Start the stack
run: docker compose up -d --wait --wait-timeout 180
- name: Test the pages in a real browser
run: ./tests/ui-test.sh
- name: Read the logs after a failure
if: failure()
run: docker compose logs --tail 60
- name: Keep the failure screenshot
if: failure()
uses: actions/upload-artifact@v4
with:
name: ui-failure-screenshot
path: tests/ui/ui-failure.png
if-no-files-found: ignore
- name: Stop the stack
if: always()
run: docker compose down -v
e2e:
name: Connect a real VPN client
runs-on: ubuntu-latest
timeout-minutes: 60
# This job answers the question that matters to a user: does a phone
# connect and get working, filtered internet? It starts the stack, makes
# client configurations the same way a user makes them, and connects real
# WireGuard clients.
#
# This job found a fault that no other test could see: wg-easy listens on
# the port from VPN_PORT inside the container, so a published port with a
# different number accepted no connection and reported no error.
steps:
- uses: actions/checkout@v4
- name: Check that the runner has WireGuard
run: |
sudo modprobe wireguard || true
lsmod | grep -q wireguard || modinfo wireguard
echo "OK: the kernel has the WireGuard module."
- name: Use the newest images on the weekly run
if: github.event_name == 'schedule'
run: |
sed -i -E 's|^(PIHOLE_VERSION)=.*|\1=latest|' .env.example
sed -i -E 's|^(UNBOUND_VERSION)=.*|\1=latest|' .env.example
sed -i -E 's|^(WG_EASY_VERSION)=.*|\1=15|' .env.example
sed -i -E 's|^(WIREGUARD_VERSION)=.*|\1=latest|' .env.example
- name: Test both back ends with real clients
run: ./tests/e2e-vpn.sh
- name: Remove anything left behind
if: always()
run: |
docker ps -aq --filter name=wirehole-e2e | xargs -r docker rm -f
docker network prune -f