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). 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
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. 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 enabled = False
debug = True 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. # Command templates for arping and iptables.
# Arping might block so make sure you use a timeout and limit the number of # Arping might block so make sure you use a timeout and limit the number of
# packets it sends. # packets it sends.
arping = -f -c 1 -w 30 -I eth0 {ip_address} 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 # This is a command to run to create iptables rules. Two arguments are
iptables_ip = -t mangle -I internet 1 -m tcp -p tcp --source {ip_address} -j RETURN # 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 # 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 re
import socket import socket
@ -13,9 +16,11 @@ except ImportError:
from portal import logHandler, logFormatter from portal import logHandler, logFormatter
# Try to import arping for mac_from_ip() # Try to import arping for mac_from_ip()
use_arping = True
try: try:
from sh import arping from sh import arping
except ImportError: except ImportError:
use_arping = False
pass pass
# By default run iptables through sudo, so the worker process must run with # By default run iptables through sudo, so the worker process must run with
@ -38,6 +43,7 @@ def run(arg):
l.setLevel(DEBUG) l.setLevel(DEBUG)
l.debug('debug logging enabled') l.debug('debug logging enabled')
# Get client IP from webapp
client_ip = environ.get( client_ip = environ.get(
'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED_FOR',
environ.get('REMOTE_ADDR') environ.get('REMOTE_ADDR')
@ -46,7 +52,7 @@ def run(arg):
error_msg = None error_msg = None
iptables_failed = False iptables_failed = False
# Verify IP # Verify client IP
try: try:
socket.inet_aton(client_ip) socket.inet_aton(client_ip)
except socket.error: except socket.error:
@ -56,100 +62,52 @@ def run(arg):
'failed': True 'failed': True
} }
# Attempt to get client HW address first. # Attempt to get client HW address with arping
try: if use_arping:
client_mac = mac_from_ip( try:
l, client_mac = mac_from_ip(
config.get('iptables', 'arping'), l,
client_ip config.get('iptables', 'arping'),
) client_ip
except Exception as e: )
l.warn('Failed to get client HW address: {error}'.format( except Exception as e:
error=str(e) l.warn('Failed to get client HW address: {error}'.format(
)) error=str(e)
error_msg = str(e) ))
pass error_msg = str(e)
pass
# If HW address was found, use it now. if client_ip:
if client_mac: iptables_cmd = config.get('iptables', 'iptables_cmd').format(
l.debug('Found client HW address: {hw}'.format( ip_address=client_ip,
hw=client_mac
))
# Create tuple out of iptables command
iptables_mac = config.get('iptables', 'iptables_mac').format(
mac_address=client_mac mac_address=client_mac
) )
iptables_mac = tuple(iptables_mac.split(' '))
output = BytesIO() output = BytesIO()
error = BytesIO() error = BytesIO()
try: try:
rc = sudo.iptables(iptables_mac, _out=output, _err=error) # The two arguments must not contain spaces of course.
rc = sudo(tuple(iptables_cmd.split(' ')), _out=output, _err=error)
if rc.exit_code == 0:
l.debug('Created iptables MAC rule successfully')
return {
'error': error_msg,
'failed': False
}
except ErrorReturnCode: except ErrorReturnCode:
error.seek(0) error.seek(0)
error_msg = error.read() error_msg = error.read()
l.warn('{cmd}: exited badly: {error}'.format( l.warn('{cmd}: exited badly: {error}'.format(
cmd=('iptables', iptables_mac), cmd=('iptables', iptables_cmd),
error=error_msg error=error_msg
)) ))
iptables_failed = True iptables_failed = True
pass pass
except Exception as e: except Exception as e:
l.warn('{cmd}: failed: {error}'.format( l.warn('{cmd}: failed: {error}'.format(
cmd=('iptables', iptables_mac), cmd=('iptables', iptables_cmd),
error=str(e) error=str(e)
)) ))
error_msg = str(e) error_msg = str(e)
iptables_failed = True iptables_failed = True
pass pass
# Fallback on IP if HW address fails if rc.exit_code == 0:
if client_ip: l.debug('Created iptables IP rule successfully')
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 all else fails, error! This will be shown to end users. # If all else fails, error! This will be shown to end users.
return { return {
@ -183,3 +141,4 @@ def mac_from_ip(l, arping_args, ip):
if line.startswith(line_start): if line.startswith(line_start):
m = re.search('(([0-9A-Fa-f]{2}[:]){5}([0-9A-Fa-f]{2}))', line) m = re.search('(([0-9A-Fa-f]{2}[:]){5}([0-9A-Fa-f]{2}))', line)
if m: return m.group(0) if m: return m.group(0)

View File

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

View File

@ -1,7 +1,7 @@
# Captiveportal web application using Bottle.py # Captiveportal web application using Bottle.py
import json import json
from pprint import pprint from pprint import pprint as pp
from uuid import UUID from uuid import UUID
from importlib import import_module from importlib import import_module
@ -128,12 +128,17 @@ def dispatch_plugins():
)) ))
continue 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() # Run plugin.run()
try: try:
plugin_job = Q.enqueue( plugin_job = Q.enqueue(
plugin_module.run, plugin_module.run,
arg, arg,
ttl=config.getint('portal', 'plugin_ttl') timeout=plugin_timeout
) )
except Exception as e: except Exception as e:
l.warn('{plugin}: {error}'.format( l.warn('{plugin}: {error}'.format(
@ -166,7 +171,7 @@ app.router.add_filter('uuid', uuid_filter)
def portalindex(): def portalindex():
return template( return template(
config.get('portal', 'index_page'), 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 'meta': job.meta
} }
return json.dumps(job_data) return json.dumps(job_data)
@app.route('/approve', method='POST') @app.route('/approve', method='POST')
def approve_client(): def approve_client():
response.content_type = 'application/json' 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) return json.dumps(jobs)

View File

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

View File

@ -1,32 +1,62 @@
// Captive portal Javascript // Captive portal Javascript
// by Stefan Midjich // by Stefan Midjich @ Cygate AB
//
// //
var debug = true; 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 // This function ensures the user gets redirect to the correct destination once
// all jobs have succeeded in the portal software. // all jobs have succeeded in the portal software.
function do_success() { 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. // If url does not start with http the window.location redirect
window.location = window.href; // won't work. So prefix http to url.
location.reload(true); 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 // Show an error to the user
function do_error(message) { function do_error(message) {
console.log('failure: '+message); $('#approveButton').prop('disabled', false);
$('#statusDiv').html('');
$('#error-box').show(); $('#error-box').show();
$('#form-row').hide(); $('#error-box').html('<p>Failed. Reload page and try again or contact support.</p> ');
$('#error-box').append('<p>Failed. Reload page and try again or contact support.</p> ');
if (message) { if (message) {
console.log('server: '+message);
$('#error-box').append('<p>System response: '+message+'</p>'); $('#error-box').append('<p>System response: '+message+'</p>');
} }
} }
// Poll the returned jobs and ensure they all succeed // Poll the returned jobs and ensure they all succeed
function poll_jobs(data) { function poll_jobs(data) {
var promises = []; var promises = [];
@ -45,7 +75,7 @@ function poll_jobs(data) {
} }
promises.push(new Promise(function(resolve, reject) { promises.push(new Promise(function(resolve, reject) {
var maxRun = plugin_ttl/2; var maxRun = plugin_timeout/2;
var timesRun = 0; var timesRun = 0;
// Timer function that polls the API for job results // Timer function that polls the API for job results
@ -61,14 +91,14 @@ function poll_jobs(data) {
console.log(job_result); console.log(job_result);
if(job_result.is_finished) { if(job_result.is_finished) {
console.log('Resolving job: ', job_result.id); console.log('Resolving job: ', job_result);
resolve(job_result); resolve(job_result);
clearTimeout(timer); clearTimeout(timer);
return(true); return(true);
} }
if(job_result.is_failed) { if(job_result.is_failed) {
console.log('Job failed: ', job_result.id); console.log('Job failed: ', job_result);
reject(job_result); reject(job_result);
clearTimeout(timer); clearTimeout(timer);
return(false); return(false);
@ -95,47 +125,46 @@ function poll_jobs(data) {
} }
// Run .all() on promises array until all promises resolve // Run .all() on promises array until all promises resolve
// This is resolve() above.
Promise.all(promises).then(function(result) { Promise.all(promises).then(function(result) {
var success = true; var success = true;
for(var i=0;i<result.length;i++) { for(var i=0;i<result.length;i++) {
console.log('Job result: ', result[i]);
var r = result[i].result; var r = result[i].result;
var m = result[i].meta; var meta = result[i].meta;
if (r.failed && m.mandatory) { if (meta.mandatory) {
do_error(r.error); if (result[i].is_finished && result[i].is_failed) {
success = false; do_error(r.error);
break; success = false;
break;
}
} }
} }
if (success) { 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) { }, function(reason) {
do_error(reason); do_error(reason);
}); });
} }
$(document).ready(function() {
$('#error-box').hide();
});
// Submit the form // Submit the form
$('#approveForm').submit(function (event) { $('#approveForm').submit(function (event) {
var api_url = '/approve'; var api_url = '/approve';
event.preventDefault(); 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')) { 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); var ajaxReq = $.post(api_url);
ajaxReq.done(poll_jobs); 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"> <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/normalize.css">
<link rel="stylesheet" href="/static/css/skeleton.css"> <link rel="stylesheet" href="/static/css/skeleton.css">
<link rel="stylesheet" href="/static/css/captiveportal.css"> <link rel="stylesheet" href="/static/css/captiveportal.css">
@ -56,6 +54,8 @@
<div class="row"> <div class="row">
<div id="error-box" class="five columns msgbox msgbox-error"> <div id="error-box" class="five columns msgbox msgbox-error">
</div> </div>
<div id="statusDiv">
</div>
</div> </div>
<div id="form-row" class="row"> <div id="form-row" class="row">
@ -66,7 +66,8 @@
</div> </div>
<div id="approveButtonDiv" class="one column u-pull-left"> <div id="approveButtonDiv" class="one column u-pull-left">
<label> <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> </label>
</div> </div>
</div> </div>
@ -75,9 +76,9 @@
</div> </div>
<script> <script>
var plugin_ttl = {{plugin_ttl}}; var plugin_timeout = {{plugin_timeout}};
</script> </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> <script src="/static/js/captiveportal.js"></script>
</body> </body>