-
Notifications
You must be signed in to change notification settings - Fork 25
251 lines (237 loc) · 12.6 KB
/
wireguard-server.yml
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
name: WireGuard Server
on:
- push
concurrency:
group: wireguard
cancel-in-progress: true
# General workflow overview:
# 1. The client pushes git changes with top commit in a specific format,
# containing the client's IP address, mapped external port and local
# source port. The mapping is detected with stun client from run.sh
# script.
# 2. This Action workflow is started on git push, checks the commit message
# 3. Continue the flow if commit message contains "WG: "
# 4. Extract client's IP address, mapped port and local source port from
# the commit message
# 5. Run STUN client using UDP source port 443 to determine server's external
# IP address and NAT-mapped port for UDP source port 443.
# 6. Run UDP NAT punch from source port 443 to client's IP address and mapped
# port with nping in the background
# 7. Run WireGuard on UDP port 443
jobs:
wireguard-server:
name: WireGuard Server
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
# Execute this job only if the commit message starts with "WG: " string.
# GitHub actions workflow does not have any native way to run job only for
# specific commit messages.
- name: 'Check if commit message starts with "WG: "'
id: check
run: >
[[ "$(git log --pretty='%s' -n1)" =~ "WG: " ]]
&& echo "::set-output name=run::true" || true
# Extract IP address, external source port and local port of the client
# from the top git commit message.
# The message format is as follows:
#
# WG: 198.51.100.123:5555:8888
#
# where 198.51.100.123 is the client's external IP address,
# 5555 is an external port (after NAT mapping if the client is behind NAT),
# 8888 is a client's source port (before NAT).
# If the client is not behind NAT or is behind port-preserving NAT, both
# ports would be the same.
# The mapped port is determined with stun client on the client side,
# check run.sh code.
#
# If the client did not specify ports in the commit message, the server
# will choose random local port for the client. Such connection would
# work only if the client is behind port-preserving NAT or no NAT, as
# otherwise the server won't know the relation between source port and
# mapped port.
#
# Only IPv4 is supported since Actions worker does not have IPv6 connectivity.
- name: Get client IP address and optional port from commit title
if: ${{ steps.check.outputs.run }}
id: user
run: >
IP="$(git log --pretty='%s' -n1 | awk '{split($2, a, /:/);print a[1]}')"
&& PORT="$(git log --pretty='%s' -n1 | awk -F ':' '{print $3}')"
&& LPORT="$(git log --pretty='%s' -n1 | awk -F ':' '{print $4}')"
&& { [[ ! "$PORT" ]] && PORT=$(( $RANDOM + 32767 )) || true; }
&& { [[ ! "$LPORT" ]] && LPORT=$PORT || true; }
&& echo "Client IP address: $IP"
&& echo "Client port (after NAT mapping): $PORT"
&& echo "Client port (before NAT mapping): $LPORT"
&& echo "::set-output name=ip::$IP"
&& echo "::set-output name=port::$PORT"
&& echo "::set-output name=lport::$LPORT"
- name: Install packages and configure system
if: ${{ steps.check.outputs.run }}
run: >
sudo rm /var/lib/man-db/auto-update
&& sudo DEBIAN_FRONTEND=noninteractive eatmydata
apt install -y --no-install-recommends wireguard openssh-server stun-client nmap
&& ([ -f authorized_keys ] && cat authorized_keys | sudo tee /root/.ssh/authorized_keys || true)
# Use STUN client with Google's STUN server to detect external IP address and mapped NAT port
# for source port 443 (used by OpenVPN/WireGuard server).
#
# Actions worker is running behind the following type of NAT, according to STUN client:
#
# Independent Mapping, Port Dependent Filter, random port, will hairpin
#
# What matters for us is:
#
# * Independent Mapping — read it as "NAT mapping independent of destination host/port".
#
# The NAT would map any request to the same mapped port coming from the same local port,
# regardless of destination IP address or destination port.
# In other words, requests
# from 192.168.0.10:5555 to 1.1.1.1:80
# would be mapped to the same NAT external address and port as the requests
# from 192.168.0.10:5555 to 8.8.8.8:443
#
# * Random port — read it as "non-source-port-preserving NAT"
#
# Some type of NATs preserve the source port for NAT mapping wherever possible, i.e.
# 192.168.0.10:5555 would be mapped to 198.51.100.123:5555 (both source ports are 5555).
# However, Actions infrastructure NAT does not preserve the port, meaning that the
# source port will be mapped to another port. In Actions case, the mapping is done
# in sequence, starting from port 1024 or 1984 (yes, really).
# For example, if you send the request
# from 192.168.0.10:5555 to 1.1.1.1:80, your 192.168.0.10:5555 would be
# mapped to 198.51.100.123:1024.
# The next request
# from 192.168.0.10:9876 to 1.1.1.1:80 would be mapped to 198.51.100.123:1025,
# and so on.
# The exact pattern of port mapping is not relevant for NAT traversal as long as it is
# Independent Mapping, it is only important that the port is not preserved, which forces
# us to determine the mapping using external services like STUN server.
#
# If it were port-preserving, there wouldn't be any need to determine port mapping.
# But since it's non-port-preserving, we'll determine the mapping for source port 443,
# which would be used to run OpenVPN/WireGuard later.
#
# * Port Dependent Filter — read it as "NAT allows incoming packets only from single IP AND port"
#
# This type of NAT, when the packet is sent from exact source IP/port to exact destination IP/port,
# accepts incoming packets only from this exact destination IP/port.
# This is the most common type of filter.
#
# If it were Independent Filter, there wouldn't be any need to send client's IP address and port to
# the server in the git commit message: NAT accepts incoming packets from any IP and port in such case.
#
# * Will hairpin
#
# Hairpinning (NAT loopback) matters only for connectivity behind the same router and
# is irrelevant for internet-wide NAT traversal.
#
# NOTE: use stun.ekiga.net or other STUN server with multiple IP addresses to properly detect NAT type.
# Google's stun.l.google.com server has only single IP address and won't allow to run mapping type test
# properly, leading to incorrect/confusing results. This does not matter for port mapping, but it does
# matter for NAT type information.
- name: Detect IP address and UDP port mapping
if: ${{ steps.check.outputs.run }}
id: ip
run: >
sudo stun -v stun.l.google.com:19302 -p 443 1 2>&1 | awk '/MappedAddress/ {
split($0, aport, /:/); split(aport[1], aip, / /); port=aport[2]; ip=aip[3];
print "Server IP address:", ip, "\n::set-output name=ip::"ip;
print "Server port map for source port 443:", port, "\n::set-output name=port::"port;
exit}'
# Punch NAT by sending empty UDP packets to the client's IP address and mapped port
# from source (non-mapped) port 443 (used by OpenVPN/WireGuard later).
# The packet would be mapped to the same external port as with stun client in
# the previous step.
# We send the packets every 28 seconds to keep the port mapping active, otherwise
# it could be forgotten and/or remapped to another NAT port, which would
# break the traversal. This is only relevant before the client gets connected
# to the VPN, as both OpenVPN and WireGuard have keep-alive intervals
# doing essentially the same — keeping NAT mapping active.
# Typical "not established" UDP session timeout is 30 seconds, hence 28 seconds
# interval.
# Empty packets do not have any effect on VPN connection when it is already
# established, that's why we don't need to terminate nping upon connection.
#
# Note important detail here: the packet is sent with low Time-To-Live value of 4,
# to be able to punch the Actions NAT but prevent the packet from getting delivered
# to the client. Why is this important, you may ask?
# Because a very common Linux NAT (mis)configuration would create and remember
# NAT mapping upon receiving incoming UDP packet (so-called "null binding"),
# which in turn would prevent the client behind said NAT to reuse the same source
# port, and nothing would work.
# Check https://www.spinics.net/lists/netfilter/msg58232.html for more information.
# If you're writing NAT traversal in your software, TTL could be set with setsockopt
# per-socket and does not require root privileges in Linux.
#
# As port 443 would be occupied by WireGuard later in the workflow sequence, nping
# is started with sudo here: in such configuration it uses RAW sockets and
# bypasses regular Linux UDP stack, resolving any conflicts with port usage,
# although that's not the only way to resolve port sharing conflict and it is
# possible to bind to the same port from different programs with SO_REUSEPORT
# socket option without root privileges.
- name: Punch NAT towards client IP address for 10 minutes (in background)
if: ${{ steps.check.outputs.run }}
run: >
sudo nping --udp --ttl 4 --no-capture --source-port 443 --count 20 --delay 28s
--dest-port ${{ steps.user.outputs.port }} ${{ steps.user.outputs.ip }} &
## Uncomment to connect to ssh-j.com service, for debugging using SSH
## outside of VPN tunnel, if the tunnel can't be established.
##
## Connect with:
## ssh -J yourloginhere@ssh-j.com root@github
##
#- name: Connect to ssh-j
# if: ${{ steps.check.outputs.run }}
# run: >
# ssh -f -o StrictHostKeyChecking=accept-new -o ExitOnForwardFailure=yes
# yourloginhere@ssh-j.com -N -R github:22:localhost:22
# Set up WireGuard server on port 443 using freshly generated key pair
# without specifying client's IP address here.
- name: Configure WireGuard
if: ${{ steps.check.outputs.run }}
run: >
wg genkey | tee privatekey | wg pubkey > publickey
&& wg genkey | tee privatekey_peer | wg pubkey > publickey_peer
&& sudo ip link add dev wg0 type wireguard
&& sudo ip address add dev wg0 192.168.166.1/30
&& sudo wg set wg0 listen-port 443 private-key privatekey peer $(cat publickey_peer)
allowed-ips 192.168.166.2/32
# Generate WireGuard configuration specifying server's IP address and mapped port,
# as well as client's local source (non-mapped) port.
- name: Print WireGuard configuration file
if: ${{ steps.check.outputs.run }}
run: |
echo "Save the following as 'github.conf', connect with: sudo wg-quick up ./github.conf"
echo "Do not forget to disconnect with: sudo wg-quick down ./github.conf"
echo "[Interface]
ListenPort = ${{ steps.user.outputs.lport }}
Address = 192.168.166.2/30
PrivateKey = $(cat privatekey_peer)
[Peer]
PublicKey = $(cat publickey)
Endpoint = ${{ steps.ip.outputs.ip }}:${{ steps.ip.outputs.port }}
AllowedIPs = 192.168.166.1/32
PersistentKeepalive = 25"
echo
echo "Connect to the server's SSH with: ssh root@192.168.166.1"
echo
echo "Configuration for onetun:"
echo onetun --endpoint-addr ${{ steps.ip.outputs.ip }}:${{ steps.ip.outputs.port }} \
--endpoint-public-key $(cat publickey) --private-key $(cat privatekey_peer) \
--source-peer-ip 192.168.166.2 --endpoint-bind-addr 0.0.0.0:${{ steps.user.outputs.lport }} \
--keep-alive 25 2222:192.168.166.1:22
echo
echo "Connect to the server's SSH with: ssh -p 2222 root@127.0.0.1"
# WireGuard is configured to run on UDP port 443.
- name: Run WireGuard
if: ${{ steps.check.outputs.run }}
timeout-minutes: 15
run: >
sudo ip link set dev wg0 up
&& sleep 365d || true