]>
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) | |
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 | ||
92a9ce54 MT |
107 | try: |
108 | while True: | |
109 | line = self._read_line() | |
339b84d5 | 110 | |
92a9ce54 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() | |
92a9ce54 | 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 | ||
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() |