]> git.ipfire.org Git - ipfire-2.x.git/blame - config/ovpn/openvpn-authenticator
openvpn-authenticator: Avoid infinite loop when losing socket connection
[ipfire-2.x.git] / config / ovpn / openvpn-authenticator
CommitLineData
339b84d5
MT
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
22import argparse
23import base64
24import csv
25import daemon
26import logging
27import logging.handlers
28import signal
29import socket
30import subprocess
31import sys
32
33OPENVPN_CONFIG = "/var/ipfire/ovpn/ovpnconfig"
34
35CHALLENGETEXT = "One Time Token: "
36
37log = logging.getLogger()
38log.setLevel(logging.DEBUG)
39
40def 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
62class 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 buf.append(char)
72
73 # Reached end of line
74 if char == b"\n":
75 break
76
77 line = b"".join(buf).decode()
78 line = line.rstrip()
79
80 log.debug("< %s" % line)
81
82 return line
83
84 def _write_line(self, line):
85 log.debug("> %s" % line)
86
87 if not line.endswith("\n"):
88 line = "%s\n" % line
89
90 # Convert into bytes
91 buf = line.encode()
92
93 # Send to socket
94 self.sock.send(buf)
95
96 def _send_command(self, command):
97 # Send the command
98 self._write_line(command)
99
339b84d5
MT
100 def run(self):
101 # Connect to socket
102 self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
103 self.sock.connect(self.socket_path)
104
105 log.info("OpenVPN Authenticator started")
106
91abc666
MT
107 try:
108 while True:
109 line = self._read_line()
92a9ce54 110
91abc666
MT
111 if line.startswith(">CLIENT"):
112 self._client_event(line)
113
114 # Terminate the daemon when it loses its connection to the OpenVPN daemon
115 except ConnectionResetError as e:
116 log.error("Connection to OpenVPN has been lost: %s" % e)
339b84d5
MT
117
118 log.info("OpenVPN Authenticator terminated")
119
120 def terminate(self, *args):
121 # XXX TODO
122 raise SystemExit
123
124 def _client_event(self, line):
125 # Strip away "CLIENT:"
126 client, delim, line = line.partition(":")
127
128 # Extract the event & split any arguments
129 event, delim, arguments = line.partition(",")
130 arguments = arguments.split(",")
131
132 environ = {}
133
59f9e413
TE
134 if event == "CONNECT":
135 environ = self._read_env(environ)
136 self._client_connect(*arguments, environ=environ)
137 elif event == "DISCONNECT":
138 environ = self._read_env(environ)
139 self._client_disconnect(*arguments, environ=environ)
140 elif event == "REAUTH":
141 environ = self._read_env(environ)
142 self._client_reauth(*arguments, environ=environ)
143 elif event == "ESTABLISHED":
144 environ = self._read_env(environ)
145 else:
146 log.debug("Unhandled event: %s" % event)
147
148 def _read_env(self, environ):
339b84d5
MT
149 # Read environment
150 while True:
151 line = self._read_line()
152
153 if not line.startswith(">CLIENT:ENV,"):
154 raise RuntimeError("Unexpected environment line: %s" % line)
155
156 # Strip >CLIENT:ENV,
157 line = line[12:]
158
159 # Done
160 if line == "END":
161 break
162
163 # Parse environment
164 key, delim, value = line.partition("=")
165 environ[key] = value
166
59f9e413 167 return environ
339b84d5
MT
168
169 def _client_connect(self, cid, kid, environ={}):
170 log.debug("Received client connect (cid=%s, kid=%s)" % (cid, kid))
171 for key in sorted(environ):
172 log.debug(" %s : %s" % (key, environ[key]))
173
174 # Fetch common name
175 common_name = environ.get("common_name")
176
177 # Find connection details
178 conn = self._find_connection(common_name)
179 if not conn:
180 log.warning("Could not find connection '%s'" % common_name)
181 # XXX deny auth?
182
183 log.debug("Found connection:")
184 for key in conn:
185 log.debug(" %s : %s" % (key, conn[key]))
186
187 # Perform no further checks if TOTP is disabled for this client
188 if not conn.get("totp_status") == "on":
189 return self._client_auth_successful(cid, kid)
190
191 # Fetch username & password
192 username = environ.get("username")
193 password = environ.get("password")
194
195 # Client sent the special password TOTP to start challenge authentication
196 if password == "TOTP":
197 return self._client_auth_challenge(cid, kid,
198 username=common_name, password="TOTP")
199
200 elif password.startswith("CRV1:"):
201 log.debug("Received dynamic challenge response %s" % password)
202
203 # Decode the string
204 (command, flags, username, password, token) = password.split(":", 5)
205
206 # Decode username
207 username = self._b64decode(username)
208
209 # Check if username matches common name
210 if username == common_name:
211 # Check if TOTP token matches
212 if self._check_totp_token(token, conn.get("totp_secret")):
472cd782 213 return self._client_auth_successful(cid, kid)
339b84d5
MT
214
215 # Restart authentication
216 self._client_auth_challenge(cid, kid,
217 username=common_name, password="TOTP")
218
219 def _client_disconnect(self, cid, environ={}):
220 """
221 Handles CLIENT:DISCONNECT events
222 """
223 pass
224
225 def _client_reauth(self, cid, kid, environ={}):
226 """
227 Handles CLIENT:REAUTH events
228 """
229 # Perform no checks
230 self._client_auth_successful(cid, kid)
231
232 def _client_auth_challenge(self, cid, kid, username, password):
233 """
234 Initiates a dynamic challenge authentication with the client
235 """
236 log.debug("Sending request for dynamic challenge...")
237
238 self._send_command(
239 "client-deny %s %s \"CRV1\" \"CRV1:R,E:%s:%s:%s\"" % (
240 cid,
241 kid,
242 self._b64encode(username),
243 self._b64encode(password),
244 self._escape(CHALLENGETEXT),
245 ),
246 )
247
248 def _client_auth_successful(self, cid, kid):
249 """
250 Sends a positive authentication response
251 """
252 log.debug("Client Authentication Successful (cid=%s, kid=%s)" % (cid, kid))
253
254 self._send_command(
255 "client-auth-nt %s %s" % (cid, kid),
256 )
257
258 @staticmethod
259 def _b64encode(s):
260 return base64.b64encode(s.encode()).decode()
261
262 @staticmethod
263 def _b64decode(s):
264 return base64.b64decode(s.encode()).decode()
91abc666 265
339b84d5
MT
266 @staticmethod
267 def _escape(s):
268 return s.replace(" ", "\ ")
269
270 def _find_connection(self, common_name):
271 with open(OPENVPN_CONFIG, "r") as f:
272 for row in csv.reader(f, dialect="unix"):
b6f9fff2
MT
273 # Skip empty rows or rows that are too short
274 if not row or len(row) < 5:
339b84d5
MT
275 continue
276
277 # Skip disabled connections
278 if not row[1] == "on":
279 continue
280
281 # Skip any net-2-net connections
282 if not row[4] == "host":
283 continue
284
285 # Skip if common name does not match
286 if not row[3] == common_name:
287 continue
288
289 # Return match!
c9dc7fde
MT
290 conn = {
291 "name" : row[2],
292 "common_name" : row[3],
293 }
294
295 # TOTP options
296 try:
297 conn |= {
298 "totp_protocol" : row[43],
299 "totp_status" : row[44],
300 "totp_secret" : row[45],
301 }
302 except IndexError:
303 pass
a4a42dae
TE
304
305 return conn
306
339b84d5
MT
307
308 def _check_totp_token(self, token, secret):
309 p = subprocess.run(
74ab6f9f 310 ["oathtool", "--totp", "-w", "3", "%s" % secret],
339b84d5
MT
311 capture_output=True,
312 )
313
314 # Catch any errors if we could not run the command
315 if p.returncode:
316 log.error("Could not run oathtool: %s" % p.stderr)
317
318 return False
319
320 # Reading returned tokens looking for a match
321 for line in p.stdout.split(b"\n"):
322 # Skip empty/last line(s)
323 if not line:
324 continue
325
326 # Decode bytes into string
327 line = line.decode()
328
329 # Return True if a token matches
330 if line == token:
331 return True
332
333 # No match
334 return False
335
336
337if __name__ == "__main__":
338 parser = argparse.ArgumentParser(description="OpenVPN Authenticator")
339
340 # Daemon Stuff
341 parser.add_argument("--daemon", "-d", action="store_true",
342 help="Launch as daemon in background")
343 parser.add_argument("--verbose", "-v", action="count", help="Be more verbose")
344
345 # Paths
346 parser.add_argument("--socket", default="/var/run/openvpn.sock",
347 metavar="PATH", help="Path to OpenVPN Management Socket")
348
349 # Parse command line arguments
350 args = parser.parse_args()
351
352 # Setup logging
353 loglevel = logging.WARN
354
355 if args.verbose:
356 if args.verbose == 1:
357 loglevel = logging.INFO
358 elif args.verbose >= 2:
359 loglevel = logging.DEBUG
360
361 # Create an authenticator
362 authenticator = OpenVPNAuthenticator(args.socket)
363
364 with daemon.DaemonContext(
365 detach_process=args.daemon,
366 stderr=None if args.daemon else sys.stderr,
367 signal_map = {
368 signal.SIGINT : authenticator.terminate,
369 signal.SIGTERM : authenticator.terminate,
370 },
371 ) as daemon:
372 setup_logging(daemon=args.daemon, loglevel=loglevel)
373
374 authenticator.run()