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