Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
36 changes: 36 additions & 0 deletions proxy-listener/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# ── Nginx ─────────────────────────────────────────────────────────────────────
# Path to the nginx configuration directory (contains nginx.conf)
NGINX_PATH='/etc/nginx'

# Command used to run nginx. Auto-discovered if left empty.
# Native: nginx
# Docker: docker exec <container_name> nginx
NGINX_BINARY=

# ── Logging ───────────────────────────────────────────────────────────────────
# Log level: DEBUG | INFO | WARNING | ERROR | CRITICAL
LOGGER_LEVEL=INFO

# Directory where daily log files are stored
LOGGER_PATH=data/logs

# ── Access-Request Landing Page (public API) ─────────────────────────────────
# Full URL of the domain that serves the access request page
SERVER_NAME=

# Host and port the public-facing access request page listens on
PUBLIC_API_HOST=
PUBLIC_API_PORT=

# ── Private API ───────────────────────────────────────────────────────────────
# Host and port of the private management API
PRIVATE_API_HOST=
PRIVATE_API_PORT=

# Credentials for authenticating with the private API (default: admin / admin)
PRIVATE_API_USERNAME=
PRIVATE_API_PASSWORD=

# ── Polling ───────────────────────────────────────────────────────────────────
# How often (in seconds) to poll the private API for connection changes
POLLING_INTERVAL=60
49 changes: 41 additions & 8 deletions proxy-listener/utilities/backend.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import sys
import time
import requests
from concurrent.futures import ThreadPoolExecutor
from utilities.logger import create_logger

log = create_logger(logger_name="ProxyListener_util_privateapi", alias="Private-API")

_ESTABLISHING = "Establishing connection with private API"
_AQUA = "\033[96m"
_RESET = "\033[0m"


def _stderr_spin_while(condition) -> None:
"""Aqua | / - \\ at line start, then _ESTABLISHING, while condition() is true."""
frames = "|/-\\"
i = 0
clear_w = max(len(_ESTABLISHING) + 10, 72)
while condition():
sys.stderr.write(
f"\r{_AQUA}{frames[i % 4]}{_RESET} {_ESTABLISHING} "
)
sys.stderr.flush()
time.sleep(0.1)
i += 1
sys.stderr.write("\r" + " " * clear_w + "\r")
sys.stderr.flush()


class Backend:

Expand All @@ -15,19 +38,23 @@ def __init__(self, host: str, port: str):
self._base_url = f"http://{host}:{port}"
self._token: str = ""

try:
while True:

self.get_status()
try:

except requests.exceptions.ConnectionError:
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(self.get_status)
_stderr_spin_while(lambda: not future.done())
future.result()

raise ConnectionError(f"Cannot reach private API at {self._base_url}")
break

except requests.exceptions.HTTPError as e:
except (ConnectionError, requests.exceptions.HTTPError):

raise ConnectionError(f"Private API status check failed: {e}")
deadline = time.monotonic() + 5
_stderr_spin_while(lambda: time.monotonic() < deadline)

log.info(f"Connected to private API at {self._base_url}")
log.info(f"Connected to private API ({self._base_url}).")

def authenticate(self, username: str, password: str) -> str:
"""POST /auth/token — obtain and store a Bearer token.
Expand Down Expand Up @@ -64,7 +91,13 @@ def _auth_headers(self) -> dict[str, str]:

def get_status(self) -> dict:
"""GET /status — no auth required."""
response = requests.get(f"{self._base_url}/status")

try:

response = requests.get(f"{self._base_url}/status")

except requests.exceptions.ConnectionError:
raise ConnectionError(f"Cannot reach private API at {self._base_url}")

response.raise_for_status()

Expand Down
11 changes: 7 additions & 4 deletions proxy-listener/utilities/nginx.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,14 @@ def generate_nginx_config_for_request_access_server(
return available

@staticmethod
def address_whitelist_config_generator(nginx_path: str, services: list[dict], connections: list[dict]) -> None:
def address_whitelist_config_generator(nginx_path: str, services: list[dict], connections: list[dict]) -> str:
"""Generate a Nginx configuration file for the address whitelist."""

for service in services:

filename = service['name'] + ".ips"
filepath = os.path.join(nginx_path, "allowed-ips", filename)
path = os.path.join(nginx_path, "allowed-ips")
filepath = os.path.join(path, filename)
allowed_ips = []

for connection in connections:
Expand All @@ -202,11 +203,13 @@ def address_whitelist_config_generator(nginx_path: str, services: list[dict], co
allowed_ips.append("deny all;")

if SERVER_NAME:
allowed_ips.append("error_page 403 = " + SERVER_NAME + "/;")

content = "error_page 403 = " + SERVER_NAME + "/;"
allowed_ips.append(content)

with open(filepath, "w") as f:
f.write("\n".join(allowed_ips))

log.info(f"Address whitelist config generated at {filepath}")

return
return path
56 changes: 51 additions & 5 deletions proxy-listener/utilities/polling.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import time
import subprocess
from dotenv import load_dotenv
from utilities.nginx import Nginx
from utilities.backend import Backend
Expand All @@ -10,6 +11,7 @@
load_dotenv(DOTENV_FILE)

POLLING_INTERVAL = int(os.getenv("POLLING_INTERVAL", 60))
DOCKER_CONFIGURATION_PRIMARY_PATH = os.getenv("DOCKER_CONFIGURATION_PRIMARY_PATH", "/etc/nginx/conf.d/")

log = create_logger(logger_name="ProxyListener_util_polling", alias="Polling")

Expand All @@ -24,20 +26,31 @@ def _fetch_all_services(self) -> list[dict] | None:

try:
all_services = self.private_api.get_service_list()

log.debug(f"Fetched {len(all_services)} service(s)")

return all_services

except Exception as e:

log.error(f"Failed to fetch services: {e}")

return None

def _fetch_all_connections(self, all_services: list[dict]) -> list[dict] | None:

try:

all_connections = self.private_api.get_connection_list(all_services=all_services)

log.debug(f"Fetched {len(all_connections)} valid connection(s)")

return all_connections

except Exception as e:

log.error(f"Failed to fetch connections: {e}")

return None

def poll_and_process(self) -> None:
Expand All @@ -52,16 +65,20 @@ def poll_and_process(self) -> None:
all_services = self._fetch_all_services()

if all_services is None:

log.warning("Skipping cycle — could not fetch services")
had_failure = True

time.sleep(POLLING_INTERVAL)
continue

all_connections = self._fetch_all_connections(all_services)

if all_connections is None:

log.warning("Skipping cycle — could not fetch connections")
had_failure = True

time.sleep(POLLING_INTERVAL)
continue

Expand All @@ -76,16 +93,45 @@ def poll_and_process(self) -> None:
log.info(f"Connection change detected — {len(all_connections)} active connection(s)")
previous_fetch_connections = all_connections

if not all_connections:
log.info("Connection list is now empty, skipping nginx update")
time.sleep(POLLING_INTERVAL)
continue
# if not all_connections:
# log.info("Connection list is now empty, skipping nginx update")
# time.sleep(POLLING_INTERVAL)
# continue

Nginx.address_whitelist_config_generator(
path = Nginx.address_whitelist_config_generator(
nginx_path=self.nginx_path,
services=all_services,
connections=all_connections,
)

saved = os.environ.get("NGINX_BINARY", "").strip()

if saved and saved.startswith("docker"):

# Copy the files to the docker container! (Overwrite)

container_name = Nginx._find_nginx_docker_container()
container_id = Nginx._get_docker_container_id(container_name)

result = subprocess.run(
f"docker cp {path} {container_name}:{DOCKER_CONFIGURATION_PRIMARY_PATH}",
shell=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)

if result.returncode != 0:
log.error(f"Failed to copy files to docker container {container_name} (ID: {container_id})")
continue

n = sum(
os.path.getsize(os.path.join(dp, f))
for dp, _, fns in os.walk(path)
for f in fns
)
sz = f"{n} B" if n < 1024 else f"{n / 1024:.1f} KB" if n < 1024**2 else f"{n / 1024**2:.1f} MB"
log.info(f"Copied {sz} to docker container {container_name} (ID: {container_id})")

log.info("Nginx whitelist configs updated, reloading nginx")

Nginx.nginx_run("-s", "reload")
Expand Down
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[project]
name = "proxy-listener"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"colorama>=0.4.6",
"pydantic>=2.13.2",
"python-dotenv>=1.2.2",
"requests>=2.33.1",
]
Loading