Development

This commit is contained in:
Matthew Grotke 2026-06-07 16:27:16 -04:00
parent 19f0bfa79c
commit 687e0a66d1
8 changed files with 142 additions and 1 deletions

View file

@ -18,7 +18,7 @@ def collect_tokens(cfg):
'Set Restricted VLAN = Captive Portal on the Network Layout page.' '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', '') tokens['CAPTIVE_HTTPS_DOMAIN'] = cp.get('https_domain', '')
display_rows = [] display_rows = []

View file

@ -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"]

View file

@ -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 {}

View file

@ -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)

View file

@ -0,0 +1,89 @@
import ipaddress
from flask import Blueprint, request, redirect
import config_utils
bp = Blueprint('portal', __name__)
PORTAL_HTML = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
body {{ font-family: sans-serif; max-width: 480px; margin: 60px auto; padding: 0 1rem; }}
h1 {{ font-size: 1.5rem; margin-bottom: .5rem; }}
.err {{ color: #c00; margin: .75rem 0; }}
.terms label {{ display: block; margin: .4rem 0; cursor: pointer; }}
button {{ margin-top: 1rem; padding: .55rem 1.4rem; }}
</style>
</head>
<body>
<h1>{title}</h1>
{splash_html}
{error_html}
<form method="post">
<input type="hidden" name="next" value="{next_url}">
<div class="terms">{terms_html}</div>
<button type="submit">Continue</button>
</form>
</body>
</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'<label><input type="checkbox" name="term_{i}" required> {t}</label>'
for i, t in enumerate(terms)
) or '<p>No terms required.</p>'
return PORTAL_HTML.format(
title=vlan.get('portal_splash_title', 'Guest Portal'),
splash_html=f'<p>{vlan["portal_splash_text"]}</p>' if vlan.get('portal_splash_text') else '',
error_html=f'<p class="err">{error}</p>' if error else '',
terms_html=terms_html,
next_url=next_url,
)
@bp.route('/', defaults={'path': ''}, methods=['GET', 'POST'])
@bp.route('/<path:path>', 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

View file

@ -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

View file

@ -0,0 +1 @@
flask