]>
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 | ||
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! | |
c9dc7fde MT |
297 | conn = { |
298 | "name" : row[2], | |
299 | "common_name" : row[3], | |
300 | } | |
301 | ||
302 | # TOTP options | |
303 | try: | |
304 | conn |= { | |
305 | "totp_protocol" : row[43], | |
306 | "totp_status" : row[44], | |
307 | "totp_secret" : row[45], | |
308 | } | |
309 | except IndexError: | |
310 | pass | |
a4a42dae TE |
311 | |
312 | return conn | |
313 | ||
339b84d5 MT |
314 | |
315 | def _check_totp_token(self, token, secret): | |
316 | p = subprocess.run( | |
74ab6f9f | 317 | ["oathtool", "--totp", "-w", "3", "%s" % secret], |
339b84d5 MT |
318 | capture_output=True, |
319 | ) | |
320 | ||
321 | # Catch any errors if we could not run the command | |
322 | if p.returncode: | |
323 | log.error("Could not run oathtool: %s" % p.stderr) | |
324 | ||
325 | return False | |
326 | ||
327 | # Reading returned tokens looking for a match | |
328 | for line in p.stdout.split(b"\n"): | |
329 | # Skip empty/last line(s) | |
330 | if not line: | |
331 | continue | |
332 | ||
333 | # Decode bytes into string | |
334 | line = line.decode() | |
335 | ||
336 | # Return True if a token matches | |
337 | if line == token: | |
338 | return True | |
339 | ||
340 | # No match | |
341 | return False | |
342 | ||
343 | ||
344 | if __name__ == "__main__": | |
345 | parser = argparse.ArgumentParser(description="OpenVPN Authenticator") | |
346 | ||
347 | # Daemon Stuff | |
348 | parser.add_argument("--daemon", "-d", action="store_true", | |
349 | help="Launch as daemon in background") | |
350 | parser.add_argument("--verbose", "-v", action="count", help="Be more verbose") | |
351 | ||
352 | # Paths | |
353 | parser.add_argument("--socket", default="/var/run/openvpn.sock", | |
354 | metavar="PATH", help="Path to OpenVPN Management Socket") | |
355 | ||
356 | # Parse command line arguments | |
357 | args = parser.parse_args() | |
358 | ||
359 | # Setup logging | |
360 | loglevel = logging.WARN | |
361 | ||
362 | if args.verbose: | |
363 | if args.verbose == 1: | |
364 | loglevel = logging.INFO | |
365 | elif args.verbose >= 2: | |
366 | loglevel = logging.DEBUG | |
367 | ||
368 | # Create an authenticator | |
369 | authenticator = OpenVPNAuthenticator(args.socket) | |
370 | ||
371 | with daemon.DaemonContext( | |
372 | detach_process=args.daemon, | |
373 | stderr=None if args.daemon else sys.stderr, | |
374 | signal_map = { | |
375 | signal.SIGINT : authenticator.terminate, | |
376 | signal.SIGTERM : authenticator.terminate, | |
377 | }, | |
378 | ) as daemon: | |
379 | setup_logging(daemon=args.daemon, loglevel=loglevel) | |
380 | ||
381 | authenticator.run() |