]> git.ipfire.org Git - ipfire-2.x.git/blob - config/ovpn/openvpn-authenticator
openvpn-authenticator: Break read loop when daemon goes away
[ipfire-2.x.git] / config / ovpn / openvpn-authenticator
1 #!/usr/bin/python3
2 ###############################################################################
3 # #
4 # IPFire.org - A linux based firewall #
5 # Copyright (C) 2022 Michael Tremer #
6 # #
7 # This program is free software: you can redistribute it and/or modify #
8 # it under the terms of the GNU General Public License as published by #
9 # the Free Software Foundation, either version 3 of the License, or #
10 # (at your option) any later version. #
11 # #
12 # This program is distributed in the hope that it will be useful, #
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of #
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
15 # GNU General Public License for more details. #
16 # #
17 # You should have received a copy of the GNU General Public License #
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. #
19 # #
20 ###############################################################################
21
22 import argparse
23 import base64
24 import csv
25 import daemon
26 import logging
27 import logging.handlers
28 import signal
29 import socket
30 import subprocess
31 import sys
32
33 OPENVPN_CONFIG = "/var/ipfire/ovpn/ovpnconfig"
34
35 CHALLENGETEXT = "One Time Token: "
36
37 log = logging.getLogger()
38 log.setLevel(logging.DEBUG)
39
40 def setup_logging(daemon=True, loglevel=logging.INFO):
41 log.setLevel(loglevel)
42
43 # Log to syslog by default
44 handler = logging.handlers.SysLogHandler(address="/dev/log", facility="daemon")
45 log.addHandler(handler)
46
47 # Format everything
48 formatter = logging.Formatter("%(name)s[%(process)d]: %(message)s")
49 handler.setFormatter(formatter)
50
51 handler.setLevel(loglevel)
52
53 # If we are running in foreground, we should write everything to the console, too
54 if not daemon:
55 handler = logging.StreamHandler()
56 log.addHandler(handler)
57
58 handler.setLevel(loglevel)
59
60 return log
61
62 class OpenVPNAuthenticator(object):
63 def __init__(self, socket_path):
64 self.socket_path = socket_path
65
66 def _read_line(self):
67 buf = []
68
69 while True:
70 char = self.sock.recv(1)
71
72 # Break if we could not read from the socket
73 if not char:
74 raise EOFError("Could not read from socket")
75
76 # Append to buffer
77 buf.append(char)
78
79 # Reached end of line
80 if char == b"\n":
81 break
82
83 line = b"".join(buf).decode()
84 line = line.rstrip()
85
86 log.debug("< %s" % line)
87
88 return line
89
90 def _write_line(self, line):
91 log.debug("> %s" % line)
92
93 if not line.endswith("\n"):
94 line = "%s\n" % line
95
96 # Convert into bytes
97 buf = line.encode()
98
99 # Send to socket
100 self.sock.send(buf)
101
102 def _send_command(self, command):
103 # Send the command
104 self._write_line(command)
105
106 def run(self):
107 # Connect to socket
108 self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
109 self.sock.connect(self.socket_path)
110
111 log.info("OpenVPN Authenticator started")
112
113 try:
114 while True:
115 line = self._read_line()
116
117 if line.startswith(">CLIENT"):
118 self._client_event(line)
119
120 # Terminate the daemon when it loses its connection to the OpenVPN daemon
121 except (ConnectionResetError, EOFError) as e:
122 log.error("Connection to OpenVPN has been lost: %s" % e)
123
124 log.info("OpenVPN Authenticator terminated")
125
126 def terminate(self, *args):
127 # XXX TODO
128 raise SystemExit
129
130 def _client_event(self, line):
131 # Strip away "CLIENT:"
132 client, delim, line = line.partition(":")
133
134 # Extract the event & split any arguments
135 event, delim, arguments = line.partition(",")
136 arguments = arguments.split(",")
137
138 environ = {}
139
140 if event == "CONNECT":
141 environ = self._read_env(environ)
142 self._client_connect(*arguments, environ=environ)
143 elif event == "DISCONNECT":
144 environ = self._read_env(environ)
145 self._client_disconnect(*arguments, environ=environ)
146 elif event == "REAUTH":
147 environ = self._read_env(environ)
148 self._client_reauth(*arguments, environ=environ)
149 elif event == "ESTABLISHED":
150 environ = self._read_env(environ)
151 else:
152 log.debug("Unhandled event: %s" % event)
153
154 def _read_env(self, environ):
155 # Read environment
156 while True:
157 line = self._read_line()
158
159 if not line.startswith(">CLIENT:ENV,"):
160 raise RuntimeError("Unexpected environment line: %s" % line)
161
162 # Strip >CLIENT:ENV,
163 line = line[12:]
164
165 # Done
166 if line == "END":
167 break
168
169 # Parse environment
170 key, delim, value = line.partition("=")
171 environ[key] = value
172
173 return environ
174
175 def _client_connect(self, cid, kid, environ={}):
176 log.debug("Received client connect (cid=%s, kid=%s)" % (cid, kid))
177 for key in sorted(environ):
178 log.debug(" %s : %s" % (key, environ[key]))
179
180 # Fetch common name
181 common_name = environ.get("common_name")
182
183 # Find connection details
184 conn = self._find_connection(common_name)
185 if not conn:
186 log.warning("Could not find connection '%s'" % common_name)
187 # XXX deny auth?
188
189 log.debug("Found connection:")
190 for key in conn:
191 log.debug(" %s : %s" % (key, conn[key]))
192
193 # Perform no further checks if TOTP is disabled for this client
194 if not conn.get("totp_status") == "on":
195 return self._client_auth_successful(cid, kid)
196
197 # Fetch username & password
198 username = environ.get("username")
199 password = environ.get("password")
200
201 # Client sent the special password TOTP to start challenge authentication
202 if password == "TOTP":
203 return self._client_auth_challenge(cid, kid,
204 username=common_name, password="TOTP")
205
206 elif password.startswith("CRV1:"):
207 log.debug("Received dynamic challenge response %s" % password)
208
209 # Decode the string
210 (command, flags, username, password, token) = password.split(":", 5)
211
212 # Decode username
213 username = self._b64decode(username)
214
215 # Check if username matches common name
216 if username == common_name:
217 # Check if TOTP token matches
218 if self._check_totp_token(token, conn.get("totp_secret")):
219 return self._client_auth_successful(cid, kid)
220
221 # Restart authentication
222 self._client_auth_challenge(cid, kid,
223 username=common_name, password="TOTP")
224
225 def _client_disconnect(self, cid, environ={}):
226 """
227 Handles CLIENT:DISCONNECT events
228 """
229 pass
230
231 def _client_reauth(self, cid, kid, environ={}):
232 """
233 Handles CLIENT:REAUTH events
234 """
235 # Perform no checks
236 self._client_auth_successful(cid, kid)
237
238 def _client_auth_challenge(self, cid, kid, username, password):
239 """
240 Initiates a dynamic challenge authentication with the client
241 """
242 log.debug("Sending request for dynamic challenge...")
243
244 self._send_command(
245 "client-deny %s %s \"CRV1\" \"CRV1:R,E:%s:%s:%s\"" % (
246 cid,
247 kid,
248 self._b64encode(username),
249 self._b64encode(password),
250 self._escape(CHALLENGETEXT),
251 ),
252 )
253
254 def _client_auth_successful(self, cid, kid):
255 """
256 Sends a positive authentication response
257 """
258 log.debug("Client Authentication Successful (cid=%s, kid=%s)" % (cid, kid))
259
260 self._send_command(
261 "client-auth-nt %s %s" % (cid, kid),
262 )
263
264 @staticmethod
265 def _b64encode(s):
266 return base64.b64encode(s.encode()).decode()
267
268 @staticmethod
269 def _b64decode(s):
270 return base64.b64decode(s.encode()).decode()
271
272 @staticmethod
273 def _escape(s):
274 return s.replace(" ", "\ ")
275
276 def _find_connection(self, common_name):
277 with open(OPENVPN_CONFIG, "r") as f:
278 for row in csv.reader(f, dialect="unix"):
279 # Skip empty rows or rows that are too short
280 if not row or len(row) < 5:
281 continue
282
283 # Skip disabled connections
284 if not row[1] == "on":
285 continue
286
287 # Skip any net-2-net connections
288 if not row[4] == "host":
289 continue
290
291 # Skip if common name does not match
292 if not row[3] == common_name:
293 continue
294
295 # Return match!
296 conn = {
297 "name" : row[2],
298 "common_name" : row[3],
299 }
300
301 # TOTP options
302 try:
303 conn |= {
304 "totp_protocol" : row[43],
305 "totp_status" : row[44],
306 "totp_secret" : row[45],
307 }
308 except IndexError:
309 pass
310
311 return conn
312
313
314 def _check_totp_token(self, token, secret):
315 p = subprocess.run(
316 ["oathtool", "--totp", "-w", "3", "%s" % secret],
317 capture_output=True,
318 )
319
320 # Catch any errors if we could not run the command
321 if p.returncode:
322 log.error("Could not run oathtool: %s" % p.stderr)
323
324 return False
325
326 # Reading returned tokens looking for a match
327 for line in p.stdout.split(b"\n"):
328 # Skip empty/last line(s)
329 if not line:
330 continue
331
332 # Decode bytes into string
333 line = line.decode()
334
335 # Return True if a token matches
336 if line == token:
337 return True
338
339 # No match
340 return False
341
342
343 if __name__ == "__main__":
344 parser = argparse.ArgumentParser(description="OpenVPN Authenticator")
345
346 # Daemon Stuff
347 parser.add_argument("--daemon", "-d", action="store_true",
348 help="Launch as daemon in background")
349 parser.add_argument("--verbose", "-v", action="count", help="Be more verbose")
350
351 # Paths
352 parser.add_argument("--socket", default="/var/run/openvpn.sock",
353 metavar="PATH", help="Path to OpenVPN Management Socket")
354
355 # Parse command line arguments
356 args = parser.parse_args()
357
358 # Setup logging
359 loglevel = logging.WARN
360
361 if args.verbose:
362 if args.verbose == 1:
363 loglevel = logging.INFO
364 elif args.verbose >= 2:
365 loglevel = logging.DEBUG
366
367 # Create an authenticator
368 authenticator = OpenVPNAuthenticator(args.socket)
369
370 with daemon.DaemonContext(
371 detach_process=args.daemon,
372 stderr=None if args.daemon else sys.stderr,
373 signal_map = {
374 signal.SIGINT : authenticator.terminate,
375 signal.SIGTERM : authenticator.terminate,
376 },
377 ) as daemon:
378 setup_logging(daemon=args.daemon, loglevel=loglevel)
379
380 authenticator.run()