]> git.ipfire.org Git - ipfire-2.x.git/blob - config/ovpn/openvpn-authenticator
openvpn-authenticator: Drop some dead code
[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 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
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
107 try:
108 while True:
109 line = self._read_line()
110
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)
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
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):
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
167 return environ
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")):
213 return self._client_auth_successful(cid, kid)
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()
265
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"):
273 # Skip empty rows or rows that are too short
274 if not row or len(row) < 5:
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!
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
304
305 return conn
306
307
308 def _check_totp_token(self, token, secret):
309 p = subprocess.run(
310 ["oathtool", "--totp", "-w", "3", "%s" % secret],
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
337 if __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()