Merge branch 'master' of ssh+git://github.com/stemid/captiveportal into rs

This commit is contained in:
Stefan Midjich 2016-12-14 20:36:42 +01:00
commit 1d2ec580e3
13 changed files with 282 additions and 118 deletions

View File

@ -11,6 +11,10 @@ This is a commonly seen setup in public Wifi networks or hotspots.
This app was specifically written for such a hotspot and as such requires a lot of other configuration around it. This is an ongoing [documentation project here](https://wiki.sydit.se/teknik:guider:networking:captive_portal_med_iptables).
## More documentation
I've moved all examples from the [aforementioned wiki-page](https://wiki.sydit.se/teknik:guider:networking:captive_portal_med_iptables) to the docs/examples directory.
# Plugins
Plugins are executed when the user clicks through the captive portal form, whether they submit data or just approve an EULA these plugins are executed.

View File

@ -0,0 +1,9 @@
# IPtables example
The example is written for Ansible so it contains Jinja2 brackets for things like input NIC, output NIC and other items important for a captive portal firewall configuration.
Server configurations vary so it's not applicable to any situation but it might help as guidance and it's well commented.
# Script examples
They're also written as Ansible templates because I copy them straight from my Ansible playbooks for deploying the captive portal. But it matters less for them, no important values to keep track of, everything is argument input.

View File

@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Captiveportal iptables wrapper script
#iptables_mac = iptables -t mangle -I internet 1 -m mac --mac-source {mac_address} -j RETURN
# First argument must be IP-address of client
test -n "$1" || exit 1
client_ip="$1"
ipt=/sbin/iptables
# Enable client traffic in internet chain by jumping over the mark
$ipt -t mangle -I internet 1 -p tcp --source "$client_ip" -j RETURN &>/dev/null && \
$ipt -t mangle -I internet 1 -p udp --source "$client_ip" -j RETURN &>/dev/null
iptables_rc=$?
# Delete conntrack info for client IP
/usr/local/sbin/rmtrack.sh "$client_ip" &>/dev/null
rmtrack_rc=$?
if [[ $iptables_rc == 0 && $rmtrack_rc == 0 ]]; then
# Success
exit 0
else
echo "Error: iptables[$iptables_rc], rmtrack[$rmtrack_rc]" 1&>2
exit 1
fi

View File

@ -0,0 +1,98 @@
# {{ ansible_managed }}
#
# These rules are for the Captive Portal project.
# by Stefan Midjich - 2016/03
# Routing of traffic requires: sysctl net.ipv4.ip_forward = 1
#
# {{captiveportal_conf.input_nic}} is LAN and used as default route on LAN clients.
# {{captiveportal_conf.output_nic}} is WAN.
# {{captiveportal_conf.webportal_ip}} is the same as IP on {{captiveportal_conf.input_nic}}
# Mangle table allows the marking of traffic. If you use -j RETURN before
# -j MARK you jump out of the internet chain and your traffic is not marked.
*mangle
:PREROUTING ACCEPT
:INPUT ACCEPT
:OUTPUT ACCEPT
:POSTROUTING ACCEPT
# Create custom chain in mangle table called "internet"
:internet - [0:0]
# Run all traffic from {{captiveportal_conf.input_nic}} through the internet chain
-A PREROUTING -i {{captiveportal_conf.input_nic}} -j internet
# Example to allow authorized clients in by MAC address
#-A internet -m mac --mac-source "xx:xx:xx:xx:56:eb" -j RETURN
# Live example: -I internet 1 -m mac --mac-source "xx:xx:xx:xx:56:eb" -j RETURN
# inserts at the top of the rules before the mark rule.
#
# iptables -t mangle -I internet -m tcp -p tcp --source 1.2.3.4 -j RETURN
# iptables -t mangle -I internet -m udp -p udp --source 1.2.3.4 -j RETURN
# For MGMT SSH traffic return out of internet chain so it's not marked
-A internet -p tcp -d {{captiveportal_conf.webportal_ip}} --dport ssh -j RETURN
# Bypass NTP also
#-A internet -p udp --dport ntp -j RETURN
# Mark all other traffic in the internet chain with 99. Any traffic after
# this rule is marked and blocked.
-A internet -j MARK --set-mark 99
COMMIT
# NAT rules that redirect traffic and allow the portal server to act as gateway.
*nat
:PREROUTING ACCEPT
:INPUT ACCEPT
:OUTPUT ACCEPT
:POSTROUTING ACCEPT
# Redirect all marked HTTP traffic to the webportal IP
-A PREROUTING -m mark --mark 99 -p tcp --dport http -j DNAT --to-destination {{captiveportal_conf.webportal_ip}}
-A PREROUTING -m mark --mark 99 -p tcp --dport https -j DNAT --to-destination {{captiveportal_conf.webportal_ip}}
# Redirect all marked DNS traffic to the webportal IP
-A PREROUTING -m mark --mark 99 -p udp --dport domain -j DNAT --to-destination {{captiveportal_conf.webportal_ip}}
-A PREROUTING -m mark --mark 99 -p tcp --dport domain -j DNAT --to-destination {{captiveportal_conf.webportal_ip}}
# Redirect all ICMP to the webportal IP
-A PREROUTING -m mark --mark 99 -p icmp -j DNAT --to-destination {{captiveportal_conf.webportal_ip}}
# Redirect all unmarked DNS traffic to upstream DNS servers
{% for server in captiveportal_conf.upstream_dns %}
-A PREROUTING -p udp --dport domain -j DNAT --to-destination {{server}}
-A PREROUTING -p tcp --dport domain -j DNAT --to-destination {{server}}
{% endfor %}
# Route any traffic out through the output NIC to act as gateway
-A POSTROUTING -o {{captiveportal_conf.output_nic}} -j MASQUERADE
COMMIT
# Filter rules that determine access to the portal server.
*filter
:INPUT ACCEPT
:FORWARD ACCEPT
:OUTPUT ACCEPT
# Enable stateful connections
-I OUTPUT -o {{captiveportal_conf.output_nic}} -d 0.0.0.0/0 -j ACCEPT
-I INPUT -i {{captiveportal_conf.input_nic}} -m state --state ESTABLISHED,RELATED -j ACCEPT
# Accept HTTP traffic both to the server and forwarded
-A INPUT -p tcp --dport http -j ACCEPT
-A FORWARD -p tcp --dport http -j ACCEPT
# Accept DNS traffic to self
-A INPUT -p udp --dport domain -j ACCEPT
-A INPUT -p tcp --dport domain -j ACCEPT
# Drop all other traffic marked 99
-A FORWARD -m mark --mark 99 -j DROP
-A INPUT -m mark --mark 99 -j DROP
COMMIT

View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Conntracking keeps track of active connections so even if a user
# authenticates with a captive portal and new firewall rules are
# created it will take a while before the client takes these new
# routes. So conntrack -D can expedite that process.
test -n "$1" || exit 1
client_ip=$1
conntrack_cmd=/sbin/conntrack
# Deletes all conntracking entries for connections originating from
# to webportal server IP so that hopefully new connections can be
# initiated directly to destination.
$conntrack_cmd -D --orig-src $client_ip --orig-dst {{captiveportal_conf.webportal_ip}}

View File

@ -13,13 +13,11 @@ mandatory = True
enabled = False
debug = True
# If you know you won't be able to get the clients HW address then use this.
only_ip = True
# Command templates for arping and iptables.
# Arping might block so make sure you use a timeout and limit the number of
# packets it sends.
arping = -f -c 1 -w 30 -I eth0 {ip_address}
iptables_mac = -t mangle -I internet 1 -m mac --mac-source {mac_address} -j RETURN
iptables_ip = -t mangle -I internet 1 -m tcp -p tcp --source {ip_address} -j RETURN
# This is a command to run to create iptables rules. Two arguments are
# passed and replace these two placeholders.
iptables_cmd = /usr/local/sbin/cp_iptables.sh "{ip_address}" "{mac_address}"

View File

@ -1,4 +1,7 @@
# Add an iptables rule
# This actually runs a command, so you can either define an iptables
# command or a script. See the plugins.cfg for the options that are
# replaced into the command line.
import re
import socket
@ -13,9 +16,11 @@ except ImportError:
from portal import logHandler, logFormatter
# Try to import arping for mac_from_ip()
use_arping = True
try:
from sh import arping
except ImportError:
use_arping = False
pass
# By default run iptables through sudo, so the worker process must run with
@ -38,6 +43,7 @@ def run(arg):
l.setLevel(DEBUG)
l.debug('debug logging enabled')
# Get client IP from webapp
client_ip = environ.get(
'HTTP_X_FORWARDED_FOR',
environ.get('REMOTE_ADDR')
@ -46,7 +52,7 @@ def run(arg):
error_msg = None
iptables_failed = False
# Verify IP
# Verify client IP
try:
socket.inet_aton(client_ip)
except socket.error:
@ -56,100 +62,52 @@ def run(arg):
'failed': True
}
# Attempt to get client HW address first.
try:
client_mac = mac_from_ip(
l,
config.get('iptables', 'arping'),
client_ip
)
except Exception as e:
l.warn('Failed to get client HW address: {error}'.format(
error=str(e)
))
error_msg = str(e)
pass
# Attempt to get client HW address with arping
if use_arping:
try:
client_mac = mac_from_ip(
l,
config.get('iptables', 'arping'),
client_ip
)
except Exception as e:
l.warn('Failed to get client HW address: {error}'.format(
error=str(e)
))
error_msg = str(e)
pass
# If HW address was found, use it now.
if client_mac:
l.debug('Found client HW address: {hw}'.format(
hw=client_mac
))
# Create tuple out of iptables command
iptables_mac = config.get('iptables', 'iptables_mac').format(
if client_ip:
iptables_cmd = config.get('iptables', 'iptables_cmd').format(
ip_address=client_ip,
mac_address=client_mac
)
iptables_mac = tuple(iptables_mac.split(' '))
output = BytesIO()
error = BytesIO()
try:
rc = sudo.iptables(iptables_mac, _out=output, _err=error)
if rc.exit_code == 0:
l.debug('Created iptables MAC rule successfully')
return {
'error': error_msg,
'failed': False
}
# The two arguments must not contain spaces of course.
rc = sudo(tuple(iptables_cmd.split(' ')), _out=output, _err=error)
except ErrorReturnCode:
error.seek(0)
error_msg = error.read()
l.warn('{cmd}: exited badly: {error}'.format(
cmd=('iptables', iptables_mac),
cmd=('iptables', iptables_cmd),
error=error_msg
))
iptables_failed = True
pass
except Exception as e:
l.warn('{cmd}: failed: {error}'.format(
cmd=('iptables', iptables_mac),
cmd=('iptables', iptables_cmd),
error=str(e)
))
error_msg = str(e)
iptables_failed = True
pass
# Fallback on IP if HW address fails
if client_ip:
l.debug('Using client IP: {ip}'.format(
ip=client_ip
))
iptables_ip = config.get('iptables', 'iptables_ip').format(
ip_address=client_ip
)
iptables_ip = tuple(iptables_ip.split(' '))
output = BytesIO()
error = BytesIO()
try:
rc = sudo.iptables(iptables_ip, _out=output, _err=error)
if rc.exit_code == 0:
l.debug('Created iptables IP rule successfully')
return {
'error': error_msg,
'failed': False
}
except ErrorReturnCode:
error.seek(0)
error_msg = error.read()
l.warn('{cmd}: exited badly: {error}'.format(
cmd=('iptables', iptables_ip),
error=error_msg
))
iptables_failed = True
pass
except Exception as e:
l.warn('{cmd}: failed: {error}'.format(
cmd=('iptables', iptables_ip),
error=str(e)
))
error_msg = str(e)
iptables_failed = True
pass
if rc.exit_code == 0:
l.debug('Created iptables IP rule successfully')
# If all else fails, error! This will be shown to end users.
return {
@ -183,3 +141,4 @@ def mac_from_ip(l, arping_args, ip):
if line.startswith(line_start):
m = re.search('(([0-9A-Fa-f]{2}[:]){5}([0-9A-Fa-f]{2}))', line)
if m: return m.group(0)

View File

@ -8,8 +8,9 @@ debug=True
redis_host=127.0.0.1
redis_port=6379
plugin_ttl=120
plugin_timeout=180
# Can specify an alternate webpage for the portal
index_page=portalindex
[logging]

View File

@ -1,7 +1,7 @@
# Captiveportal web application using Bottle.py
import json
from pprint import pprint
from pprint import pprint as pp
from uuid import UUID
from importlib import import_module
@ -128,12 +128,17 @@ def dispatch_plugins():
))
continue
# Let plugin run for 30 more seconds than the defined plugin_timeout
# because that value is also used by the JS code to poll the job
# status so we don't want rq to kill the job before JS has timed out.
plugin_timeout = config.getint('portal', 'plugin_timeout')+30
# Run plugin.run()
try:
plugin_job = Q.enqueue(
plugin_module.run,
arg,
ttl=config.getint('portal', 'plugin_ttl')
timeout=plugin_timeout
)
except Exception as e:
l.warn('{plugin}: {error}'.format(
@ -166,7 +171,7 @@ app.router.add_filter('uuid', uuid_filter)
def portalindex():
return template(
config.get('portal', 'index_page'),
plugin_ttl=config.get('portal', 'plugin_ttl')
plugin_timeout=config.getint('portal', 'plugin_timeout')
)
@ -203,13 +208,22 @@ def job_status(job_id):
'meta': job.meta
}
return json.dumps(job_data)
@app.route('/approve', method='POST')
def approve_client():
response.content_type = 'application/json'
jobs = dispatch_plugins()
try:
jobs = dispatch_plugins()
except Exception as e:
response.status = 500
jobs = {
'result': {
'error': str(e)
}
}
return json.dumps(jobs)

View File

@ -18,3 +18,7 @@
color: #D8000C;
background-color: #FFBABA;
}
#error-box {
display:none;
}

View File

@ -1,32 +1,62 @@
// Captive portal Javascript
// by Stefan Midjich
//
// by Stefan Midjich @ Cygate AB
//
var debug = true;
function getUrlParameter(sParam, default_value) {
var sPageURL = decodeURIComponent(window.location.search.substring(1)),
sURLVariables = sPageURL.split('&'),
sParameterName,
i;
for (i = 0; i < sURLVariables.length; i++) {
sParameterName = sURLVariables[i].split('=');
if (sParameterName[0] === sParam) {
return sParameterName[1] === undefined ? true : sParameterName[1];
}
}
return default_value;
}
// This function ensures the user gets redirect to the correct destination once
// all jobs have succeeded in the portal software.
function do_success() {
console.log('success: '+window.location);
var url = getUrlParameter('url', 'www.google.com');
// Do something like refresh the window or go to another URL.
window.location = window.href;
location.reload(true);
// If url does not start with http the window.location redirect
// won't work. So prefix http to url.
if (!url.startsWith('http')) {
url = 'http://'+url;
}
console.log('success: '+url);
$('#error-box').html('<p>If you\'re not automatically redirected open your browser and try any website manually.</p>');
$('#error-box').show();
$('#statusDiv').html('');
$('#approveButton').prop('disabled', false);
// Redirect user to the url paramter.
window.location = url;
}
// Show an error to the user
function do_error(message) {
console.log('failure: '+message);
$('#approveButton').prop('disabled', false);
$('#statusDiv').html('');
$('#error-box').show();
$('#form-row').hide();
$('#error-box').append('<p>Failed. Reload page and try again or contact support.</p> ');
$('#error-box').html('<p>Failed. Reload page and try again or contact support.</p> ');
if (message) {
console.log('server: '+message);
$('#error-box').append('<p>System response: '+message+'</p>');
}
}
// Poll the returned jobs and ensure they all succeed
function poll_jobs(data) {
var promises = [];
@ -45,7 +75,7 @@ function poll_jobs(data) {
}
promises.push(new Promise(function(resolve, reject) {
var maxRun = plugin_ttl/2;
var maxRun = plugin_timeout/2;
var timesRun = 0;
// Timer function that polls the API for job results
@ -61,14 +91,14 @@ function poll_jobs(data) {
console.log(job_result);
if(job_result.is_finished) {
console.log('Resolving job: ', job_result.id);
console.log('Resolving job: ', job_result);
resolve(job_result);
clearTimeout(timer);
return(true);
}
if(job_result.is_failed) {
console.log('Job failed: ', job_result.id);
console.log('Job failed: ', job_result);
reject(job_result);
clearTimeout(timer);
return(false);
@ -95,47 +125,46 @@ function poll_jobs(data) {
}
// Run .all() on promises array until all promises resolve
// This is resolve() above.
Promise.all(promises).then(function(result) {
var success = true;
for(var i=0;i<result.length;i++) {
console.log('Job result: ', result[i]);
var r = result[i].result;
var m = result[i].meta;
if (r.failed && m.mandatory) {
do_error(r.error);
success = false;
break;
var meta = result[i].meta;
if (meta.mandatory) {
if (result[i].is_finished && result[i].is_failed) {
do_error(r.error);
success = false;
break;
}
}
}
if (success) {
do_success();
// This is for Steve...
// Apple devices don't poll their captiveportal URL,
// so this is for them. Android devices will do their
// own polling and close the wifi-portal before this.
setTimeout(do_success, 30000);
}
// This is reject() above.
}, function(reason) {
do_error(reason);
});
}
$(document).ready(function() {
$('#error-box').hide();
});
// Submit the form
$('#approveForm').submit(function (event) {
var api_url = '/approve';
event.preventDefault();
$('#error-box').hide();
$('#approveButton').prop('disabled', true);
$('#statusDiv').html('<img src="/static/images/radio.svg" alt="Loading, please wait..." />');
// Had some issues trying to set a background image on the button, so I'm
// just replacing it.
if ($('#approveCheckbox').is(':checked')) {
$('#approveButton').prop('disabled', true);
$('#approveButton').val('');
$('#approveButton').addClass('button-loading');
$('#approveButtonDiv').replaceWith('<img src="/static/images/radio.svg" alt="Loading, please wait..." />');
var ajaxReq = $.post(api_url);
ajaxReq.done(poll_jobs);

5
static/js/jquery-1.12.2.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -9,8 +9,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="//fonts.googleapis.com/css?family=Raleway:400,300,600" rel="stylesheet" type="text/css">
<link rel="stylesheet" href="/static/css/normalize.css">
<link rel="stylesheet" href="/static/css/skeleton.css">
<link rel="stylesheet" href="/static/css/captiveportal.css">
@ -56,6 +54,8 @@
<div class="row">
<div id="error-box" class="five columns msgbox msgbox-error">
</div>
<div id="statusDiv">
</div>
</div>
<div id="form-row" class="row">
@ -66,7 +66,8 @@
</div>
<div id="approveButtonDiv" class="one column u-pull-left">
<label>
<input id="approveButton" class="button-primary" value="Approve" type="submit">
<button class="button-primary" id="approveButton" type="submit">Approve</button>
<!--<input id="approveButton" class="button-primary" value="Approve" type="submit">-->
</label>
</div>
</div>
@ -75,9 +76,9 @@
</div>
<script>
var plugin_ttl = {{plugin_ttl}};
var plugin_timeout = {{plugin_timeout}};
</script>
<script src="https://code.jquery.com/jquery-1.12.2.min.js"></script>
<script src="/static/js/jquery-1.12.2.min.js"></script>
<script src="/static/js/captiveportal.js"></script>
</body>