]>
Commit | Line | Data |
---|---|---|
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 | ||
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) | |
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 | ||
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() |