Commit e9b31e5c authored by Vitaly Lipatov's avatar Vitaly Lipatov

gateway: protect external API access

parent b299ef3a
...@@ -37,6 +37,11 @@ ...@@ -37,6 +37,11 @@
# API server for checking if IP is banned # API server for checking if IP is banned
listen_host = 127.0.0.1 listen_host = 127.0.0.1
listen_port = 8275 listen_port = 8275
# Required when listen_host is not a literal loopback address. Send it as:
# Authorization: Bearer <api_token>
#api_token = change-this-to-a-long-random-secret
# Per-client request limit. Default: 60.
#rate_limit_per_minute = 60
[AutoUnban] [AutoUnban]
# Enable automatic unbanning with exponential backoff # Enable automatic unbanning with exponential backoff
......
...@@ -6,8 +6,12 @@ import json ...@@ -6,8 +6,12 @@ import json
import subprocess import subprocess
import ipaddress import ipaddress
import configparser import configparser
import hmac
import os import os
import sys import sys
import threading
import time
from collections import defaultdict, deque
LISTEN_HOST = '127.0.0.1' LISTEN_HOST = '127.0.0.1'
LISTEN_PORT = 8275 LISTEN_PORT = 8275
...@@ -19,6 +23,18 @@ IPSET_WHITE = 'eterban_white' ...@@ -19,6 +23,18 @@ IPSET_WHITE = 'eterban_white'
IPSET_WHITE_V6 = 'eterban_white_ipv6' IPSET_WHITE_V6 = 'eterban_white_ipv6'
path_to_config = '/etc/eterban/settings.ini' path_to_config = '/etc/eterban/settings.ini'
API_TOKEN = ''
API_RATE_LIMIT = 60
request_times = defaultdict(deque)
request_lock = threading.Lock()
def is_loopback_host(host):
"""Return True only for literal loopback addresses."""
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def ipset_test(setname, ip): def ipset_test(setname, ip):
...@@ -64,36 +80,58 @@ def check_ip(ip_str): ...@@ -64,36 +80,58 @@ def check_ip(ip_str):
class EterbanAPIHandler(http.server.BaseHTTPRequestHandler): class EterbanAPIHandler(http.server.BaseHTTPRequestHandler):
def _send_json(self, status, result):
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(result).encode())
def _authorized(self):
if not API_TOKEN:
return True
header = self.headers.get('Authorization', '')
return header.startswith('Bearer ') and hmac.compare_digest(header[7:], API_TOKEN)
def _rate_limited(self):
now = time.monotonic()
client = self.client_address[0]
with request_lock:
timestamps = request_times[client]
while timestamps and timestamps[0] <= now - 60:
timestamps.popleft()
if len(timestamps) >= API_RATE_LIMIT:
return True
timestamps.append(now)
return False
def do_GET(self): def do_GET(self):
if self._rate_limited():
self._send_json(429, {'error': 'rate limit exceeded'})
return
if not self._authorized():
self._send_json(401, {'error': 'authentication required'})
return
# /check/<ip> # /check/<ip>
if self.path.startswith('/check/'): if self.path.startswith('/check/'):
ip_str = self.path[len('/check/'):] ip_str = self.path[len('/check/'):]
result = check_ip(ip_str) result = check_ip(ip_str)
status = 400 if 'error' in result else 200 status = 400 if 'error' in result else 200
self.send_response(status) self._send_json(status, result)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(result).encode())
return return
# /health # /health
if self.path == '/health': if self.path == '/health':
self.send_response(200) self._send_json(200, {'status': 'ok'})
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({'status': 'ok'}).encode())
return return
self.send_response(404) self._send_json(404, {'error': 'not found', 'usage': '/check/<ip>'})
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({'error': 'not found', 'usage': '/check/<ip>'}).encode())
def log_message(self, format, *args): def log_message(self, format, *args):
pass # suppress default logging sys.stderr.write('%s - %s\n' % (self.client_address[0], format % args))
def main(): def main():
global API_TOKEN, API_RATE_LIMIT
host = LISTEN_HOST host = LISTEN_HOST
port = LISTEN_PORT port = LISTEN_PORT
...@@ -103,6 +141,13 @@ def main(): ...@@ -103,6 +141,13 @@ def main():
config.read(path_to_config) config.read(path_to_config)
host = config.get('API', 'listen_host', fallback=LISTEN_HOST) host = config.get('API', 'listen_host', fallback=LISTEN_HOST)
port = config.getint('API', 'listen_port', fallback=LISTEN_PORT) port = config.getint('API', 'listen_port', fallback=LISTEN_PORT)
API_TOKEN = config.get('API', 'api_token', fallback='').strip()
API_RATE_LIMIT = config.getint('API', 'rate_limit_per_minute', fallback=60)
if not is_loopback_host(host) and not API_TOKEN:
raise RuntimeError('API token is required for a non-loopback listen_host')
if API_RATE_LIMIT < 1:
raise RuntimeError('API rate_limit_per_minute must be positive')
server = http.server.HTTPServer((host, port), EterbanAPIHandler) server = http.server.HTTPServer((host, port), EterbanAPIHandler)
print(f"Eterban API listening on {host}:{port}") print(f"Eterban API listening on {host}:{port}")
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment