]> git.ipfire.org Git - ipfire-2.x.git/blame - config/ovpn/openvpn-authenticator
openvpn-authenticator: Break read loop when daemon goes away
[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)
3ee19987
MT
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
339b84d5
MT
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
339b84d5
MT
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
91abc666
MT
113 try:
114 while True:
115 line = self._read_line()
92a9ce54 116
91abc666
MT
117 if line.startswith(">CLIENT"):
118 self._client_event(line)
119
120 # Terminate the daemon when it loses its connection to the OpenVPN daemon
3ee19987 121 except (ConnectionResetError, EOFError) as e:
91abc666 122 log.error("Connection to OpenVPN has been lost: %s" % e)
339b84d5
MT
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
59f9e413
TE
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):
339b84d5
MT
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
59f9e413 173 return environ
339b84d5
MT
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")):
472cd782 219 return self._client_auth_successful(cid, kid)
339b84d5
MT
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()
91abc666 271
339b84d5
MT
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"):
b6f9fff2
MT
279 # Skip empty rows or rows that are too short
280 if not row or len(row) < 5:
339b84d5
MT
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!
c9dc7fde
MT
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
a4a42dae
TE
310
311 return conn
312
339b84d5
MT
313
314 def _check_totp_token(self, token, secret):
315 p = subprocess.run(
74ab6f9f 316 ["oathtool", "--totp", "-w", "3", "%s" % secret],
339b84d5
MT
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
343if __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()