]> git.ipfire.org Git - thirdparty/freeradius-server.git/commitdiff
Add HTTP <-> RADIUS healthcheck gateway
authorArran Cudbard-Bell <a.cudbardb@freeradius.org>
Tue, 23 May 2023 22:29:22 +0000 (18:29 -0400)
committerArran Cudbard-Bell <a.cudbardb@freeradius.org>
Tue, 23 May 2023 22:29:30 +0000 (18:29 -0400)
scripts/health/radhttpcheck/README.md [new file with mode: 0644]
scripts/health/radhttpcheck/dictionary [new file with mode: 0644]
scripts/health/radhttpcheck/radhttpcheck.py [new file with mode: 0755]
scripts/health/radhttpcheck/radhttpcheck.service [new file with mode: 0644]
scripts/health/radhttpcheck/radhttpcheck.yml [new file with mode: 0644]
scripts/health/radhttpcheck/requirements.txt [new file with mode: 0644]

diff --git a/scripts/health/radhttpcheck/README.md b/scripts/health/radhttpcheck/README.md
new file mode 100644 (file)
index 0000000..74aa04f
--- /dev/null
@@ -0,0 +1,108 @@
+# radhttpcheck
+## Introduction
+No cloud providers currently support sending RADIUS packets from their cloud native load balancers.
+
+In order to actively monitor FreeRADIUS (or other RADIUS servers) in these environments, we need to
+provide a HTTP service which sends RADIUS packets on behalf of the load balancer
+
+This script provides a HTTP <-> RADIUS gateway, sending pre-configured RADIUS packets to a IP/Port
+and translating the response codes in HTTP response codes.
+
+## How it works
+The configuration file allows one or more healthchecks to be configured, these healthchecks, when accessed
+with HTTP GET, send a RADIUS request to a given ip/port (usually localhost, port 1812/1813).
+
+The underlying HTTP library forks a new thread for each access, and each thread opens a new UDP
+socket to ensure there are never conflicting source port or IP allocations.
+
+No caching is performed, and each HTTP GET results in a new RADIUS packet being sent.  One or more
+retries can be configured, with an N second timeout.
+
+The process is entirely synchronous, which, given the relatively low volume of requests, is fine,
+but you should ensure the healthcheck server is NOT accessible from the wider internet.
+
+## Configuration
+By default this script loads its configuration from `radhttpcheck.yml`
+
+### Example
+```yaml
+listen:
+  # Address we bind to
+  address: '*'
+  # HTTP port to listen on
+  port: 8080
+# URLs the healthcheck script will respond on, and the various types of requests they create
+healthchecks:
+  '/acct':
+    port: 1813
+    secret: testing123
+    type: Accounting-Request
+    retries: 3
+    timeout: 1
+    attributes:
+      Acct-Session-Id: '0123456789'
+      Acct-Status-Type: 'Start'
+  '/auth':
+    port: 1812
+    secret: testing123
+    type: Access-Request
+  '/customEndpoint':
+    port: 101812
+    secret: foo
+    type: 29
+dictionary: /usr/local/radhttpcheck/dictionary
+```
+
+### `listen`
+| attr          | default          | comment                                                  |
+|---------------|------------------|----------------------------------------------------------|
+| `address`     | `*`              | Interface we listen for HTTP requests on                 |
+| `port`        | `8080`           | Port we listen for HTTP requests on                      |
+
+### `healthchecks`
+
+healthchecks is a dictionary with keys representing the URL that will trigger the healthcheck
+and a dict containing the healthcheck configuration.
+
+| attr          | default          | comment                                                  |
+|---------------|------------------|----------------------------------------------------------|
+| `port`        | set by type      | UDP port we send RADIUS requests to                      |
+| `secret`      | `testing123`     | RADIUS shared secret                                     |
+| `type`        | set by port      | Request packet type, `Access-Request`, `Accounting-Request`, `CoA-Request`, `Disconnect-Request`, `Status-Server`, or the packet code as an integer value |
+| `retries`     | `1`              | How many times we resend the request on timeout          |
+| `timeout`     | `1`              | How long we wait for a response                          |
+| `attributes`  | `{}`             | A dictionary of RADIUS attributes to send in the request, each attribute can be sent once |
+| `require_ack` | False            | Whether we require a positive acknowledgement i.e. `Access-Accept` for `Access-Request`, `CoA-ACK` for `CoA-Request` to count the healthcheck as successful.  When `False`, any response is OK |
+
+### `dictionary`
+
+The path to the RADIUS attribute dictionary file, defaults to `dictionary`.
+
+## Dictionary format and contents
+
+A pyrad compatible dictionary file 'dictionary' is available in this directory.  This is the aggregate
+of RFC 2865, 2866, and 2869 with any FreeRADIUS v4 syntax that PyRad didn't like removed.
+
+You may customise it to add additional vendor attributes, but be aware PyRad uses the old style v3
+dictionary format.
+
+## HTTP response codes
+
+As this script mostly acts as a gateway between the HTTP client, and RADIUS server, HTTP gateway response
+codes are used to indicate errors.
+
+| code          | meaning           | comment                                                  |
+|---------------|-------------------|----------------------------------------------------------|
+| `200`         | Success           | We received a valid response from the RADIUS server      |
+| `500`         | Script failure    | An internal error ocurred in the healthcheck script      |
+| `502`         | Invalid response  | Either the response packet was malformed or failed validation (bad shared secret), or `require_ack` was enabled, and the response contained a NAK response like `Access-Reject` |
+| `504`         | Timeout           | No response received from the RADIUS server              |
+
+In all cases a JSON blob will be received in the format `{ 'msg": "<extended response message>" }`
+
+## Built-in HTTP endpoints
+
+| endpoint      | usage             | comment                                                  |
+|---------------|-------------------|----------------------------------------------------------|
+| `/alwaysOk`   | CoA/DM src ports  | Sometimes CoA/DM src ports need to be routed via a loadbalancer, this endpoint ensures the healthchecks never fail so long as the CoA/DM server is reachable |
+| `/list`       | Show healthchecks | List all the available healthchecks                      |
diff --git a/scripts/health/radhttpcheck/dictionary b/scripts/health/radhttpcheck/dictionary
new file mode 100644 (file)
index 0000000..4d5f0f3
--- /dev/null
@@ -0,0 +1,223 @@
+# -*- text -*-
+# Copyright (C) 2023 The FreeRADIUS Server project and contributors
+# This work is licensed under CC-BY version 4.0 https://creativecommons.org/licenses/by/4.0
+# Version $Id$
+#
+#      This is a modified version of the RFC 2865/2866/2869 dictionaries which works with PyRad
+#
+#      $Id$
+#
+ATTRIBUTE      User-Name                               1       string
+ATTRIBUTE      User-Password                           2       string encrypt=1
+ATTRIBUTE      CHAP-Password                           3       octets
+ATTRIBUTE      NAS-IP-Address                          4       ipaddr
+ATTRIBUTE      NAS-Port                                5       integer
+ATTRIBUTE      Service-Type                            6       integer
+ATTRIBUTE      Framed-Protocol                         7       integer
+ATTRIBUTE      Framed-IP-Address                       8       ipaddr
+ATTRIBUTE      Framed-IP-Netmask                       9       ipaddr
+ATTRIBUTE      Framed-Routing                          10      integer
+ATTRIBUTE      Filter-Id                               11      string
+ATTRIBUTE      Framed-MTU                              12      integer
+ATTRIBUTE      Framed-Compression                      13      integer
+ATTRIBUTE      Login-IP-Host                           14      ipaddr
+ATTRIBUTE      Login-Service                           15      integer
+ATTRIBUTE      Login-TCP-Port                          16      integer
+# Attribute 17 is undefined
+ATTRIBUTE      Reply-Message                           18      string
+ATTRIBUTE      Callback-Number                         19      string
+ATTRIBUTE      Callback-Id                             20      string
+# Attribute 21 is undefined
+ATTRIBUTE      Framed-Route                            22      string
+ATTRIBUTE      Framed-IPX-Network                      23      ipaddr
+ATTRIBUTE      State                                   24      octets
+ATTRIBUTE      Class                                   25      octets
+
+ATTRIBUTE      Session-Timeout                         27      integer
+ATTRIBUTE      Idle-Timeout                            28      integer
+ATTRIBUTE      Termination-Action                      29      integer
+ATTRIBUTE      Called-Station-Id                       30      string
+ATTRIBUTE      Calling-Station-Id                      31      string
+ATTRIBUTE      NAS-Identifier                          32      string
+ATTRIBUTE      Proxy-State                             33      octets
+ATTRIBUTE      Login-LAT-Service                       34      string
+ATTRIBUTE      Login-LAT-Node                          35      string
+ATTRIBUTE      Login-LAT-Group                         36      octets
+ATTRIBUTE      Framed-AppleTalk-Link                   37      integer
+ATTRIBUTE      Framed-AppleTalk-Network                38      integer
+ATTRIBUTE      Framed-AppleTalk-Zone                   39      string
+
+ATTRIBUTE      Acct-Status-Type                        40      integer
+ATTRIBUTE      Acct-Delay-Time                         41      integer
+ATTRIBUTE      Acct-Input-Octets                       42      integer
+ATTRIBUTE      Acct-Output-Octets                      43      integer
+ATTRIBUTE      Acct-Session-Id                         44      string
+ATTRIBUTE      Acct-Authentic                          45      integer
+ATTRIBUTE      Acct-Session-Time                       46      integer
+ATTRIBUTE      Acct-Input-Packets                      47      integer
+ATTRIBUTE      Acct-Output-Packets                     48      integer
+ATTRIBUTE      Acct-Terminate-Cause                    49      integer
+ATTRIBUTE      Acct-Multi-Session-Id                   50      string
+ATTRIBUTE      Acct-Link-Count                         51      integer
+
+#      Accounting Status Types
+
+VALUE  Acct-Status-Type                Start                   1
+VALUE  Acct-Status-Type                Stop                    2
+VALUE  Acct-Status-Type                Alive                   3   # dup
+VALUE  Acct-Status-Type                Interim-Update          3
+VALUE  Acct-Status-Type                Accounting-On           7
+VALUE  Acct-Status-Type                Accounting-Off          8
+VALUE  Acct-Status-Type                Failed                  15
+
+#      Authentication Types
+
+VALUE  Acct-Authentic                  RADIUS                  1
+VALUE  Acct-Authentic                  Local                   2
+VALUE  Acct-Authentic                  Remote                  3
+VALUE  Acct-Authentic                  Diameter                4
+
+#      Acct Terminate Causes
+
+VALUE  Acct-Terminate-Cause            User-Request            1
+VALUE  Acct-Terminate-Cause            Lost-Carrier            2
+VALUE  Acct-Terminate-Cause            Lost-Service            3
+VALUE  Acct-Terminate-Cause            Idle-Timeout            4
+VALUE  Acct-Terminate-Cause            Session-Timeout         5
+VALUE  Acct-Terminate-Cause            Admin-Reset             6
+VALUE  Acct-Terminate-Cause            Admin-Reboot            7
+VALUE  Acct-Terminate-Cause            Port-Error              8
+VALUE  Acct-Terminate-Cause            NAS-Error               9
+VALUE  Acct-Terminate-Cause            NAS-Request             10
+VALUE  Acct-Terminate-Cause            NAS-Reboot              11
+VALUE  Acct-Terminate-Cause            Port-Unneeded           12
+VALUE  Acct-Terminate-Cause            Port-Preempted          13
+VALUE  Acct-Terminate-Cause            Port-Suspended          14
+VALUE  Acct-Terminate-Cause            Service-Unavailable     15
+VALUE  Acct-Terminate-Cause            Callback                16
+VALUE  Acct-Terminate-Cause            User-Error              17
+VALUE  Acct-Terminate-Cause            Host-Request            18
+
+ATTRIBUTE      CHAP-Challenge                          60      octets
+ATTRIBUTE      NAS-Port-Type                           61      integer
+ATTRIBUTE      Port-Limit                              62      integer
+ATTRIBUTE      Login-LAT-Port                          63      string
+
+#
+#      Integer Translations
+#
+
+#      Service types
+
+VALUE  Service-Type                    Login-User              1
+VALUE  Service-Type                    Framed-User             2
+VALUE  Service-Type                    Callback-Login-User     3
+VALUE  Service-Type                    Callback-Framed-User    4
+VALUE  Service-Type                    Outbound-User           5
+VALUE  Service-Type                    Administrative-User     6
+VALUE  Service-Type                    NAS-Prompt-User         7
+VALUE  Service-Type                    Authenticate-Only       8
+VALUE  Service-Type                    Callback-NAS-Prompt     9
+VALUE  Service-Type                    Call-Check              10
+VALUE  Service-Type                    Callback-Administrative 11
+
+#      Framed Protocols
+
+VALUE  Framed-Protocol                 PPP                     1
+VALUE  Framed-Protocol                 SLIP                    2
+VALUE  Framed-Protocol                 ARAP                    3
+VALUE  Framed-Protocol                 Gandalf-SLML            4
+VALUE  Framed-Protocol                 Xylogics-IPX-SLIP       5
+VALUE  Framed-Protocol                 X.75-Synchronous        6
+
+#      Framed Routing Values
+
+VALUE  Framed-Routing                  None                    0
+VALUE  Framed-Routing                  Broadcast               1
+VALUE  Framed-Routing                  Listen                  2
+VALUE  Framed-Routing                  Broadcast-Listen        3
+
+#      Framed Compression Types
+
+VALUE  Framed-Compression              None                    0
+VALUE  Framed-Compression              Van-Jacobson-TCP-IP     1
+VALUE  Framed-Compression              IPX-Header-Compression  2
+VALUE  Framed-Compression              Stac-LZS                3
+
+#      Login Services
+
+VALUE  Login-Service                   Telnet                  0
+VALUE  Login-Service                   Rlogin                  1
+VALUE  Login-Service                   TCP-Clear               2
+VALUE  Login-Service                   PortMaster              3
+VALUE  Login-Service                   LAT                     4
+VALUE  Login-Service                   X25-PAD                 5
+VALUE  Login-Service                   X25-T3POS               6
+VALUE  Login-Service                   TCP-Clear-Quiet         8
+
+#      Login-TCP-Port          (see /etc/services for more examples)
+
+VALUE  Login-TCP-Port                  Telnet                  23
+VALUE  Login-TCP-Port                  Rlogin                  513
+VALUE  Login-TCP-Port                  Rsh                     514
+
+#      Termination Options
+
+VALUE  Termination-Action              Default                 0
+VALUE  Termination-Action              RADIUS-Request          1
+
+#      NAS Port Types
+
+VALUE  NAS-Port-Type                   Async                   0
+VALUE  NAS-Port-Type                   Sync                    1
+VALUE  NAS-Port-Type                   ISDN                    2
+VALUE  NAS-Port-Type                   ISDN-V120               3
+VALUE  NAS-Port-Type                   ISDN-V110               4
+VALUE  NAS-Port-Type                   Virtual                 5
+VALUE  NAS-Port-Type                   PIAFS                   6
+VALUE  NAS-Port-Type                   HDLC-Clear-Channel      7
+VALUE  NAS-Port-Type                   X.25                    8
+VALUE  NAS-Port-Type                   X.75                    9
+VALUE  NAS-Port-Type                   G.3-Fax                 10
+VALUE  NAS-Port-Type                   SDSL                    11
+VALUE  NAS-Port-Type                   ADSL-CAP                12
+VALUE  NAS-Port-Type                   ADSL-DMT                13
+VALUE  NAS-Port-Type                   IDSL                    14
+VALUE  NAS-Port-Type                   Ethernet                15
+VALUE  NAS-Port-Type                   xDSL                    16
+VALUE  NAS-Port-Type                   Cable                   17
+VALUE  NAS-Port-Type                   Wireless-Other          18
+VALUE  NAS-Port-Type                   Wireless-802.11         19
+
+ATTRIBUTE      Acct-Input-Gigawords                    52      integer
+ATTRIBUTE      Acct-Output-Gigawords                   53      integer
+
+ATTRIBUTE      Event-Timestamp                         55      date
+
+ATTRIBUTE      ARAP-Password                           70      octets[16]
+ATTRIBUTE      ARAP-Features                           71      octets[14]
+ATTRIBUTE      ARAP-Zone-Access                        72      integer
+ATTRIBUTE      ARAP-Security                           73      integer
+ATTRIBUTE      ARAP-Security-Data                      74      string
+ATTRIBUTE      Password-Retry                          75      integer
+ATTRIBUTE      Prompt                                  76      integer
+ATTRIBUTE      Connect-Info                            77      string
+ATTRIBUTE      Configuration-Token                     78      string
+ATTRIBUTE      EAP-Message                             79      octets concat
+ATTRIBUTE      Message-Authenticator                   80      octets
+
+ATTRIBUTE      ARAP-Challenge-Response                 84      octets[8]
+ATTRIBUTE      Acct-Interim-Interval                   85      integer
+# 86: RFC 2867
+ATTRIBUTE      NAS-Port-Id                             87      string
+ATTRIBUTE      Framed-Pool                             88      string
+
+#      ARAP Zone Access
+
+VALUE  ARAP-Zone-Access                Default-Zone            1
+VALUE  ARAP-Zone-Access                Zone-Filter-Inclusive   2
+VALUE  ARAP-Zone-Access                Zone-Filter-Exclusive   4
+
+#      Prompt
+VALUE  Prompt                          No-Echo                 0
+VALUE  Prompt                          Echo                    1
diff --git a/scripts/health/radhttpcheck/radhttpcheck.py b/scripts/health/radhttpcheck/radhttpcheck.py
new file mode 100755 (executable)
index 0000000..5765867
--- /dev/null
@@ -0,0 +1,252 @@
+#!/usr/bin/env python3
+
+#
+#  This program is free software; you can redistribute it and/or modify
+#  it under the terms of the GNU General Public License as published by
+#  the Free Software Foundation; either version 2 of the License, or
+#  (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program; if not, write to the Free Software
+#  Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
+#
+#  Copyright (C) 2023 Arran Cudbard-Bell <a.cudbardb@freeradius.org>
+#
+
+from http.server import HTTPServer, BaseHTTPRequestHandler
+from socketserver import ThreadingMixIn
+import threading
+
+from pyrad.client import Client, Timeout
+from pyrad.dictionary import Dictionary
+import pyrad.packet
+
+import json
+import yaml
+
+# Our configuration object
+config = {}
+raddict = {}
+
+class RadiusHealthCheckHandler(BaseHTTPRequestHandler):
+    def genericResponse(self, code, content):
+        self.send_response(code)
+        self.send_header("Content-Type", "application/json")
+        self.send_header("Content-Length", str(len(content)))
+        self.end_headers()
+        self.wfile.write(bytes(content, 'utf8'))
+
+    def codeToStr(self, code):
+        code_map = {
+            pyrad.packet.AccessRequest : 'Access-Request',
+            pyrad.packet.AccessAccept : 'Access-Accept',
+            pyrad.packet.AccessReject : 'Access-Reject',
+            pyrad.packet.AccountingRequest : 'Accounting-Request',
+            pyrad.packet.AccountingResponse : 'Accounting-Response',
+            pyrad.packet.AccessChallenge : 'Access-Challenge',
+            pyrad.packet.StatusServer : 'Status-Server',
+            pyrad.packet.StatusClient : 'Status-Client',
+            pyrad.packet.DisconnectRequest : 'Disconnect-Request',
+            pyrad.packet.DisconnectACK : 'Disconnect-Ack',
+            pyrad.packet.DisconnectNAK : 'Disconnect-NAK',
+            pyrad.packet.CoARequest : 'CoA-Request',
+            pyrad.packet.CoAACK : 'CoA-ACK',
+            pyrad.packet.CoANAK : 'CoA-NAK'
+        }
+        if code in code_map:
+            return code_map[code]
+        return str(code)
+
+    def do_GET(self):
+        global config
+
+        if self.path == '/alwaysOk':
+            self.genericResponse(200, json.dumps({"msg": "This healthcheck is always up, and should be used for RADIUS source ports (for CoA and DM) only"}))
+            return
+
+        if self.path == '/list':
+            self.genericResponse(200, json.dumps(list(config.healthchecks.keys())))
+            return
+
+        if not self.path in config.healthchecks:
+            self.genericResponse(404, json.dumps({"msg": "Invalid healthcheck " + self.path + ".  Configured healthchecks are \"" + ', '.join(config.healthchecks.keys()) + "\""}))
+            return
+
+        # Send a RADIUS request
+        healthcheck = config.healthchecks[self.path]
+
+        # Create a new client for every request, this ensures that for the lifetime of the client
+        # a unique source port is used, and so we don't have to care about synchronisation around
+        # the different client instances when multiple HTTP requests come in for the same
+        # healthcheck.
+        port = healthcheck['port']
+        client = Client(server = healthcheck['server'],
+                        secret = bytes(healthcheck['secret'], 'utf8'),
+                        retries = healthcheck['retries'],
+                        timeout = healthcheck['timeout'],
+                        authport = port, acctport = port, coaport = port,
+                        dict = config.raddict)
+
+        # Create the RADIUS request
+        if healthcheck['type']['req_code'] == pyrad.packet.AccessRequest:
+            req = client.CreateAuthPacket(**healthcheck['attributes'])
+        elif healthcheck['type']['req_code'] == pyrad.packet.AccountingRequest:
+            req = client.CreateAcctPacket(**healthcheck['attributes'])
+        elif healthcheck['type']['req_code'] == pyrad.packet.CoARequest:
+            req = client.CreateCoAPacket(**healthcheck['attributes'])
+        elif healthcheck['type']['req_code'] == pyrad.packet.StatusServer:
+            req = client.CreateAuthPacket(code=pyrad.packet.StatusServer,**healthcheck['attributes'])
+        else:
+            req = client.CreatePacket(code=healthcheck['type']['req_code'],**healthcheck['attributes'])
+
+        # There's no reason not to add this or to make it configurable
+        req.add_message_authenticator()
+
+        # We now block until retries and timeout have expired
+        try:
+            rsp = client.SendPacket(req)
+        except pyrad.packet.PacketError as e:
+            self.genericResponse(502, json.dumps({"msg": "Healthcheck error: " + str(e) })) # BadGateway
+        except pyrad.client.Timeout as e:
+            self.genericResponse(504, json.dumps({"msg": "Healthcheck error: No response from upstream"})) # Gateway timeout
+        except Exception as e:
+            self.genericResponse(500, json.dumps({"msg": "Internal error: " + str(e) })) # Internal error
+        finally:
+            del client # Ensure the socket is closed in a timely fashion
+            return
+
+        # Deal with response code mismatches
+        if healthcheck['require_ack'] and healthcheck['type'].has_key('rsp_code') and rsp.code != healthcheck['type']['rsp_code']:
+            self.genericResponse(502, json.dumps({"msg": "Healthcheck error: Bad response code, expected " + code2str(healthcheck['type']['rsp_code']) + ", got " + code2str(rsp.code) })) # BadGateway
+            return
+
+        self.genericResponse(200, json.dumps({"msg": "Healthcheck OK" }))
+
+class Configuration:
+    def __init__(self, configuration_filename='radhttpcheck.yml'):
+        if configuration_filename is None:
+            raise ValueError("Configuration filename must be supplied")
+        self._configuration_filename = configuration_filename
+        self._config = {}
+        self.read_configuration()
+
+    def read_configuration(self):
+        packet_types = {
+            'access-request': {
+                'req_code': pyrad.packet.AccessRequest,
+                'rsp_code': pyrad.packet.AccessAccept
+            },
+            'accounting-request': {
+                'req_code': pyrad.packet.AccountingRequest,
+                'rsp_code': pyrad.packet.AccountingResponse
+            },
+            'coa-request': {
+                'req_code': pyrad.packet.CoARequest,
+                'rsp_code': pyrad.packet.CoAACK
+            },
+            'disconnect-request': {
+                'req_code': pyrad.packet.DisconnectRequest,
+                'rsp_code': pyrad.packet.DisconnectACK
+            },
+            'status-server': {
+                'req_code': pyrad.packet.StatusServer
+            }
+        }
+
+        with open(self._configuration_filename, 'r') as file:
+            our_conf = yaml.safe_load(file)
+
+        # Ensure basic keys and structures exist
+        our_conf = { 'listen' : {}, 'healthchecks' : {}, 'dictionary' : 'dictionary' } | our_conf
+
+        # Load in our modified default RADIUS dictionary.  We do this here to avoid parsing the
+        # dictionary file on every request
+        self.raddict = Dictionary(our_conf['dictionary'])
+
+        # Configure defaults for the HTTP listener
+        our_conf['listen'] = { 'port': 8080, 'ipaddr': '' } | our_conf['listen']
+
+        # SimpleHTTP tries to resolve '*' and fails.  An empty string means bing to any interface
+        if our_conf['listen']['ipaddr'] == '*':
+            our_conf['listen']['ipaddr'] = ''
+
+        # Setup packet-specific defaults on the healthchecks
+        for healthcheck in our_conf['healthchecks'].keys():
+            options = our_conf['healthchecks'][healthcheck]
+            options['type'] = options['type'].lower()
+            # Set different defaults depending on whether this an Access-Request or something else
+            if ('port' in options and options['port'] == '1812') or ('type' in options and options['type'] == 'access-request'):
+                our_conf['healthchecks'][healthcheck] = {
+                    'port': 1812,
+                    'type': 'access-request',
+                } | options
+            else:
+                our_conf['healthchecks'][healthcheck] = {
+                    'port': 1813,
+                    'type': 'accounting-request',
+                } | options
+
+            our_conf['healthchecks'][healthcheck] = {
+                    'server': '127.0.0.1',
+                    'retries': 1,
+                    'timeout': 1,
+                    'require_ack': False,
+                    'secret': 'testing123',
+                    'attributes': {},
+                } | options
+
+            # Make sure the packet type is sane
+            if not options['type'] in packet_types:
+                # If type is a number, allow it so we can send custom packets
+                if not options['type'].isnumeric():
+                    raise ValueError("healthcheck.type must be one of " + ', '.join(list(packet_types.keys())))
+                our_conf['healthchecks'][healthcheck]['type'] = { 'req_code': int(options['type']) }
+            else:
+                our_conf['healthchecks'][healthcheck]['type'] = packet_types[options['type']]
+
+            # Sanity check the attributes so we can fail early
+            for attr, value in our_conf['healthchecks'][healthcheck]['attributes'].items():
+                if not attr in self.raddict:
+                    raise ValueError("Failed resolving RADIUS attribute " + attr + " for healthcheck " + healthcheck)
+
+                radattr = self.raddict[attr]
+
+                # Resolve enums
+                if len(radattr.values) > 0:
+                    if not radattr.values.HasForward(value):
+                        raise ValueError("Failed resolving RADIUS attribute " + attr + " value " + str(value) + " for healthcheck " + healthcheck)
+
+        # Set default healthcheck parameters
+        self._config = our_conf
+
+    def __getattr__(self, name):
+        return self._config[name]
+
+class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
+    """Handle requests in a separate thread."""
+
+def main():
+    global config
+    global raddict
+
+    # Parse our configuration, setting defaults
+    config = Configuration()
+
+    # Start the HTTP server
+    with ThreadedHTTPServer((config.listen['ipaddr'], config.listen['port']), RadiusHealthCheckHandler) as httpd:
+        print("RADIUS HTTP healthcheck server running on port", config.listen['port'])
+        try:
+            httpd.serve_forever()
+        # Catch the KeyboardInterrupt exception we get on sigint
+        except KeyboardInterrupt:
+            pass
+        finally:
+            httpd.server_close()
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/health/radhttpcheck/radhttpcheck.service b/scripts/health/radhttpcheck/radhttpcheck.service
new file mode 100644 (file)
index 0000000..20450d5
--- /dev/null
@@ -0,0 +1,16 @@
+[Unit]
+Description=radhttpcheck health probe translator
+After=network-online.target
+Documentation=raddhtpcheck/README.md
+
+[Service]
+Type=exec
+ExecStart=/usr/local/radhttpcheck/radhttpcheck.py
+Restart=on-failure
+RestartSec=5
+
+# Allow binding to low ports like 80
+CapabilityBoundingSet=CAP_NET_BIND_SERVICE
+
+[Install]
+WantedBy=multi-user.target
diff --git a/scripts/health/radhttpcheck/radhttpcheck.yml b/scripts/health/radhttpcheck/radhttpcheck.yml
new file mode 100644 (file)
index 0000000..545cba3
--- /dev/null
@@ -0,0 +1,17 @@
+# Address listened on for HTTP traffic
+listen:
+  address: '*'
+  port: 8000
+# URLs the healthcheck script will respond on, and the various types of requests they create
+endpoints:
+  '/acct':
+    port: 1813
+    secret: testing123
+    type: Accounting-Request
+    attributes:
+      Acct-Session-Id: '0123456789'
+      Acct-Status-Type: 'Start'
+  '/auth':
+    port: 1812
+    secret: testing123
+    type: Access-Request
diff --git a/scripts/health/radhttpcheck/requirements.txt b/scripts/health/radhttpcheck/requirements.txt
new file mode 100644 (file)
index 0000000..04565f3
--- /dev/null
@@ -0,0 +1,4 @@
+pyyaml
+pyrad
+pyjson
+simple-http-server