diff --git a/docker/routlin-dash/app/pages/captiveportal/view.py b/docker/routlin-dash/app/pages/captiveportal/view.py index 5173384..5fedfb9 100644 --- a/docker/routlin-dash/app/pages/captiveportal/view.py +++ b/docker/routlin-dash/app/pages/captiveportal/view.py @@ -18,7 +18,7 @@ def collect_tokens(cfg): 'Set Restricted VLAN = Captive Portal on the Network Layout page.' ) - tokens['CAPTIVE_HTTP_PORT'] = str(cp.get('http_port', 8081)) + tokens['CAPTIVE_HTTP_PORT'] = str(cp.get('http_port', 25328)) tokens['CAPTIVE_HTTPS_DOMAIN'] = cp.get('https_domain', '') display_rows = [] diff --git a/docker/routlin-portal/Dockerfile b/docker/routlin-portal/Dockerfile new file mode 100644 index 0000000..c03a8a7 --- /dev/null +++ b/docker/routlin-portal/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app/ . +CMD ["python", "main.py"] diff --git a/docker/routlin-portal/app/config_utils.py b/docker/routlin-portal/app/config_utils.py new file mode 100644 index 0000000..58de7d0 --- /dev/null +++ b/docker/routlin-portal/app/config_utils.py @@ -0,0 +1,23 @@ +import copy, json, os + +CONFIGS_DIR = '/routlin_location' +CONFIG_FILE = f'{CONFIGS_DIR}/config.json' +CAPTIVE_QUEUE = f'{CONFIGS_DIR}/.captive-queue' + +_config_cache = None +_config_mtime = None + + +def load_config(): + global _config_cache, _config_mtime + try: + mtime = os.path.getmtime(CONFIG_FILE) + if _config_cache is not None and mtime == _config_mtime: + return copy.deepcopy(_config_cache) + with open(CONFIG_FILE) as f: + data = json.load(f) + _config_cache = data + _config_mtime = mtime + return copy.deepcopy(data) + except Exception: + return {} diff --git a/docker/routlin-portal/app/main.py b/docker/routlin-portal/app/main.py new file mode 100644 index 0000000..ccd8638 --- /dev/null +++ b/docker/routlin-portal/app/main.py @@ -0,0 +1,10 @@ +from flask import Flask +from pages.portal.page import bp as portal_bp + +app = Flask(__name__) +app.register_blueprint(portal_bp) + +if __name__ == '__main__': + import config_utils + port = config_utils.load_config().get('captive_portal', {}).get('http_port', 25328) + app.run(host='0.0.0.0', port=port) diff --git a/docker/routlin-portal/app/pages/portal/__init__.py b/docker/routlin-portal/app/pages/portal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-portal/app/pages/portal/page.py b/docker/routlin-portal/app/pages/portal/page.py new file mode 100644 index 0000000..982dc9c --- /dev/null +++ b/docker/routlin-portal/app/pages/portal/page.py @@ -0,0 +1,89 @@ +import ipaddress +from flask import Blueprint, request, redirect +import config_utils + +bp = Blueprint('portal', __name__) + +PORTAL_HTML = """\ + + + + + + {title} + + + +

{title}

+ {splash_html} + {error_html} +
+ +
{terms_html}
+ +
+ +""" + + +def _vlan_for_ip(client_ip): + cfg = config_utils.load_config() + try: + addr = ipaddress.ip_address(client_ip) + except ValueError: + return None + for vlan in cfg.get('vlans', []): + if vlan.get('restricted_vlan') != 'c': + continue + try: + net = ipaddress.ip_network(f"{vlan['ip']}/{vlan['subnet']}", strict=False) + if addr in net: + return vlan + except (KeyError, ValueError): + continue + return None + + +def _render(vlan, error=None, next_url=''): + terms = vlan.get('portal_terms', []) + terms_html = ''.join( + f'' + for i, t in enumerate(terms) + ) or '

No terms required.

' + return PORTAL_HTML.format( + title=vlan.get('portal_splash_title', 'Guest Portal'), + splash_html=f'

{vlan["portal_splash_text"]}

' if vlan.get('portal_splash_text') else '', + error_html=f'

{error}

' if error else '', + terms_html=terms_html, + next_url=next_url, + ) + + +@bp.route('/', defaults={'path': ''}, methods=['GET', 'POST']) +@bp.route('/', methods=['GET', 'POST']) +def portal(path): + vlan = _vlan_for_ip(request.remote_addr) + if vlan is None: + return 'Portal unavailable.', 404 + + if request.method == 'POST': + terms = vlan.get('portal_terms', []) + for i in range(len(terms)): + if not request.form.get(f'term_{i}'): + return _render(vlan, + error='You must accept all terms to continue.', + next_url=request.form.get('next', '')), 200 + try: + with open(config_utils.CAPTIVE_QUEUE, 'a') as f: + f.write(f'allow {request.remote_addr}\n') + except OSError: + pass + return redirect(request.form.get('next') or 'http://routlin.local/', 302) + + return _render(vlan, next_url=request.args.get('next', '')), 200 diff --git a/docker/routlin-portal/docker-compose.yml b/docker/routlin-portal/docker-compose.yml new file mode 100644 index 0000000..63c65f9 --- /dev/null +++ b/docker/routlin-portal/docker-compose.yml @@ -0,0 +1,12 @@ +name: routlin-portal + +services: + portal: + container_name: routlin-portal + build: . + ports: + - "25328:25328" + volumes: + - $HOME/routlin/config.json:/routlin_location/config.json:ro + - $HOME/routlin/.captive-queue:/routlin_location/.captive-queue + restart: unless-stopped diff --git a/docker/routlin-portal/requirements.txt b/docker/routlin-portal/requirements.txt new file mode 100644 index 0000000..7e10602 --- /dev/null +++ b/docker/routlin-portal/requirements.txt @@ -0,0 +1 @@ +flask