Commit b467c2b9 authored by rgaudin's avatar rgaudin
Browse files

Merge branch 'captive'

parents 3229c5ea 97dd3f60
......@@ -2,6 +2,7 @@
* Fixed an issue when downloading small files too fast
* Added new content offer: Nomad Education Android APK
* Updated Kiwix to version 3.0.1-6
* Added new captive portal to improve user's onboarding
2.1.0
......
......@@ -42,6 +42,8 @@ nomad_version: "2.7.0"
# Apps path
venvs_root: /home/{{ username }}/venvs
captive_root: /var/www/captiveportal
captive_env: "{{ venvs_root }}/captive"
aflatoun_root: "{{ data_path }}/aflatoun"
aflatoun_env: "{{ venvs_root }}/aflatoun"
edupi_root: "/home/{{ username }}/edupi"
......
hotspot-portal
====
## i18n
``` sh
# update message catalog
pybabel extract -F portal/babel.cfg -o portal/locale/messages.pot portal/
# add new locale (once per locale)
pybabel init -i portal/locale/messages.pot -d portal/locale -l fr
# update locales
pybabel update -i portal/locale/messages.pot -d portal/locale
# compile locales
pybabel compile -d portal/locale
```
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# vim: ai ts=4 sts=4 et sw=4 nu
import sys
import pathlib
sys.path.append(str(pathlib.Path(__file__).parent.resolve()))
from portal.web import app
if __name__ == "__main__":
app.run(host="0.0.0.0", port=80)
else:
application = app
[python: **.py]
[jinja2: **/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# vim: ai ts=4 sts=4 et sw=4 nu
import os
import pathlib
import datetime
import peewee
from portal.utils import REGISTRATION_TIMEOUT, is_active
portal_db = peewee.SqliteDatabase(
str(pathlib.Path(os.getenv("TMP_DIR", "/tmp")).joinpath("hotspot-portal.db"))
)
class User(peewee.Model):
class Meta:
database = portal_db
# ident-related fields
hw_addr = peewee.CharField(primary_key=True)
ip_addr = peewee.IPField()
# metadata
platform = peewee.CharField(null=True)
system = peewee.CharField(null=True)
system_version = peewee.FloatField(null=True)
browser = peewee.CharField(null=True)
browser_version = peewee.FloatField(null=True)
language = peewee.CharField(null=True)
# registration-related fields
last_seen_on = peewee.DateTimeField(default=datetime.datetime.now)
registered_on = peewee.DateTimeField(null=True)
@property
def is_registered(self):
""" """
if not self.registered_on:
return False
now = datetime.datetime.now()
return (
now > self.registered_on
and (now - self.registered_on).total_seconds() < REGISTRATION_TIMEOUT
)
@property
def is_being_registered(self):
""" has started but not completed registration process """
if not self.registered_on:
return False
now = datetime.datetime.now()
return self.registered_on > now
@property
def is_active(self):
return is_active(self.ip_addr)
@property
def is_apple(self):
return self.platform in ("macos", "iphone", "ipad")
@property
def is_recent_android(self):
if self.system == "Android" and self.system_version >= 7:
return True
if self.platform == "linux" and self.browser == "chrome":
return True
return False
def register(self, delay=0):
self.registered_on = datetime.datetime.now() + datetime.timedelta(seconds=delay)
self.save()
@classmethod
def create_or_update(cls, hw_addr, ip_addr, extras):
now = datetime.datetime.now()
data = {"ip_addr": ip_addr, "last_seen_on": now}
user, created = cls.get_or_create(hw_addr=hw_addr, defaults=data)
extras.update(data)
for key, value in extras.items():
if hasattr(user, key):
setattr(user, key, value)
user.save()
return user
portal_db.create_tables([User])
# French translations for PROJECT.
# Copyright (C) 2019 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2019.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2019-10-14 10:24+0000\n"
"PO-Revision-Date: 2019-10-07 09:38+0000\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: fr\n"
"Language-Team: fr <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n > 1)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.7.0\n"
#: portal/utils.py:138
msgid "Online"
msgstr "connecté"
#: portal/utils.py:138
msgid "Offline"
msgstr "hors-ligne"
#: portal/templates/portal.html:4
#, python-format
msgid "Welcome to <em>%(project_name)s</em> Hotspot."
msgstr "Bienvenue sur le Hotspot <em>%(project_name)s</em>."
#: portal/templates/portal.html:5
msgid "Access your content via: "
msgstr "Accèdez aux contenus via :"
#: portal/templates/portal.html:5
msgid "Copy to clipboard"
msgstr "Copier l'adresse"
#: portal/templates/portal.html:8
msgid ""
"<strong>You need to <u>manually</u> go to</strong> (use copy-paste) that "
"address as clicking on the button bellow might close this web page."
msgstr ""
"<strong>Vous devez <u>manuellement</u> (utilisez le copier-coller) aller "
"à</strong> cette addresse car après avoir cliqué sur le bouton ci-"
"dessous, cette page risque de se fermer."
#: portal/templates/portal.html:12
msgid ""
"When browsing a secure website, your browser might display a <em>Security"
" Warning</em> instead of displaying this page. Just open a new page onto "
"a non-secure website to register again."
msgstr ""
"Surfant sur un site sécurisé, le navigateur pourrait vous afficher un "
"<em>avertissement de sécurité</em> au lieu d'afficher cette page. Ouvrez-"
"en simplement une nouvelle sur un site non-sécurisé et enregistrez-vous à"
" nouveau."
#: portal/templates/portal.html:13
#, python-format
msgid ""
"You will be disconnected after %(interval)smn of inactivity: use this "
"address to reconnect."
msgstr ""
"Vous serez déconnecté après %(interval)smn"
" d'innactivité. Utilisez cette adresse pour"
" vous reconnecter."
#: portal/templates/portal.html:16
msgid "Agree and register"
msgstr "S'enregistrer"
#: portal/templates/registered.html:3
#, python-format
msgid ""
"Registered to “<em>%(hotspot_name)s</em>” (<a "
"href=\"http://%(fqdn)s/\">%(fqdn)s</a>)"
msgstr ""
"Enregistré sur «<em>%(hotspot_name)s</em>» (<a "
"href=\"http://%(fqdn)s/\">%(fqdn)s</a>)"
#: portal/templates/registered.html:6
msgid "You're all set!"
msgstr "C'est tout bon !"
#: portal/templates/registered.html:8
#, python-format
msgid ""
"Now open your web browser and go to <button class=\"clipboard button "
"button-sm neutral\" data-clipboard-"
"text=\"http://%(fqdn)s/\">%(fqdn)s</button> as instructed earlier."
msgstr ""
"Maintenant, ouvrez votre navigateur et allez à <button class=\"clipboard "
"button button-sm neutral\" data-clipboard-"
"text=\"http://%(fqdn)s/\">%(fqdn)s</button> en suivant les instructions "
"précédentes."
#: portal/templates/registered.html:10
#, python-format
msgid "You're now ready to access <em>%(project_name)s's content</em>."
msgstr ""
"Vous êtes désormais prêt à accèder aux <em>contenus de "
"%(project_name)s</em>."
#: portal/templates/registered.html:11
#, python-format
msgid "Go to %(fqdn)s"
msgstr "Aller à %(fqdn)s"
# Translations template for PROJECT.
# Copyright (C) 2019 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2019.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2019-10-14 10:24+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.7.0\n"
#: portal/utils.py:138
msgid "Online"
msgstr ""
#: portal/utils.py:138
msgid "Offline"
msgstr ""
#: portal/templates/portal.html:4
#, python-format
msgid "Welcome to <em>%(project_name)s</em> Hotspot."
msgstr ""
#: portal/templates/portal.html:5
msgid "Access your content via: "
msgstr ""
#: portal/templates/portal.html:5
msgid "Copy to clipboard"
msgstr ""
#: portal/templates/portal.html:8
msgid ""
"<strong>You need to <u>manually</u> go to</strong> (use copy-paste) that "
"address as clicking on the button bellow might close this web page."
msgstr ""
#: portal/templates/portal.html:12
msgid ""
"When browsing a secure website, your browser might display a <em>Security"
" Warning</em> instead of displaying this page. Just open a new page onto "
"a non-secure website to register again."
msgstr ""
#: portal/templates/portal.html:13
#, python-format
msgid ""
"You will be disconnected after %(interval)smn of inactivity: use this "
"address to reconnect."
msgstr ""
#: portal/templates/portal.html:16
msgid "Agree and register"
msgstr ""
#: portal/templates/registered.html:3
#, python-format
msgid ""
"Registered to “<em>%(hotspot_name)s</em>” (<a "
"href=\"http://%(fqdn)s/\">%(fqdn)s</a>)"
msgstr ""
#: portal/templates/registered.html:6
msgid "You're all set!"
msgstr ""
#: portal/templates/registered.html:8
#, python-format
msgid ""
"Now open your web browser and go to <button class=\"clipboard button "
"button-sm neutral\" data-clipboard-"
"text=\"http://%(fqdn)s/\">%(fqdn)s</button> as instructed earlier."
msgstr ""
#: portal/templates/registered.html:10
#, python-format
msgid "You're now ready to access <em>%(project_name)s's content</em>."
msgstr ""
#: portal/templates/registered.html:11
#, python-format
msgid "Go to %(fqdn)s"
msgstr ""
<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>{{ hotspot_name }}</title>
<link rel="icon" href="/hotspot-static/branding/favicon.png">
<link rel="stylesheet" href="/hotspot-static/font-awesome/css/font-awesome.min.css" />
<!--[if lte IE 8]>
<link rel="stylesheet" type="text/css" href="/hotspot-static/purecss/pure-css-grids-old-ie-min.css">
<![endif]-->
<!--[if gt IE 8]><!-->
<link rel="stylesheet" type="text/css" href="/hotspot-static/purecss/pure-css-grids-min.css">
<!--<![endif]-->
<link rel="stylesheet" type="text/css" href="/hotspot-static/main.css" />
<link rel="stylesheet" type="text/css" href="/hotspot-static/branding/style.css" />
<script src="/hotspot-static/clipboard.min.js"></script>
<style type="text/css">ul { list-style-type: initial; padding-left: 1em; }</style>
</head>
<body class="">
<header>
<section>
<a href="/">
<h1>
<img src="/hotspot-static/branding/logo.png"></img>
</h1>
</a>
<span class="boxid">{{ project_name }}!</span>
</section>
</header>
<div class="content">
{% block content %}{% endblock %}
</div>
<footer>
<div class="row">
<div class="col third i18n_switch"></div>
<div class="col half"></div>
</div>
</footer>
<script type="text/javascript">
(function(exports, d) {
function domReady(fn, context) {
function onReady(event) {
d.removeEventListener("DOMContentLoaded", onReady);
fn.call(context || exports, event);
}
function onReadyIe(event) {
if (d.readyState === "complete") {
d.detachEvent("onreadystatechange", onReadyIe);
fn.call(context || exports, event);
}
}
d.addEventListener && d.addEventListener("DOMContentLoaded", onReady) ||
d.attachEvent && d.attachEvent("onreadystatechange", onReadyIe);
}
exports.domReady = domReady;
})(window, document);
</script>
<script type="text/javascript">domReady(function(event) {
new ClipboardJS('.clipboard');
{% block js %}{% endblock %}
});</script>
</body>
</html>
{% extends "base.html" %}
{% block content %}
<h2>{% trans %}Welcome to <em>{{ project_name }}</em> Hotspot.{% endtrans %}</h2>
<p>{% trans %}Access your content via: {% endtrans %} <code>http://{{ fqdn }}/</code> <button class="clipboard button button-sm neutral" data-clipboard-text="http://{{ fqdn }}/">{% trans %}Copy to clipboard{% endtrans %}</button></p>
{% if action_required %}
<p>{% trans %}<strong>You need to <u>manually</u> go to</strong> (use copy-paste) that address as clicking on the button bellow might close this web page.{% endtrans %}</p>
<hr />
{% endif %}
<ul>
<li>{% trans %}When browsing a secure website, your browser might display a <em>Security Warning</em> instead of displaying this page. Just open a new page onto a non-secure website to register again.{% endtrans %}</li>
<li>{% trans interval=interval|int %}You will be disconnected after {{ interval }}mn of inactivity: use this address to reconnect.{% endtrans %}</li>
</ul>
<p><a class="button" href="/hotspot-register">{% trans %}Agree and register{% endtrans %}</a></p>
{% endblock %}
{% extends "base.html" %}
{% block content %}
<h2>{% trans %}You're all set!{% endtrans %}</h2>
{% if action_required %}
<p>{% trans %}Now open your web browser and go to <button class="clipboard button button-sm neutral" data-clipboard-text="http://{{ fqdn }}/">{{ fqdn }}</button> as instructed earlier.{% endtrans %}</p>
{% else %}
<p>{% trans %}You're now ready to access <em>{{ project_name }}'s content</em>.{% endtrans %}</p>
<p><a class="button" href="http://{{ fqdn }}" target="_system">{% trans %}Go to {{ fqdn }}{% endtrans %}</a></p>
{% endif %}
{% endblock %}
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# vim: ai ts=4 sts=4 et sw=4 nu
import re
import subprocess
from flask_babel import gettext
# seconds to expire registration after
# synced with cron job launching clean_iptables.sh
REGISTRATION_TIMEOUT = 15 * 60
APPLE_HOSTS = [
"captive.apple.com",
"appleiphonecell.com",
"*.apple.com.edgekey.net",
"gsp1.apple.com",
"apple.com",
"www.apple.com",
]
MICROSOFT_HOSTS = [
"ipv6.msftncsi.com",
"detectportal.firefox.com",
"ipv6.msftncsi.com.edgesuite.net",
"www.msftncsi.com",
"www.msftncsi.com.edgesuite.net",
"www.msftconnecttest.com",
"www.msn.com",
"teredo.ipv6.microsoft.com",
"teredo.ipv6.microsoft.com.nsatc.net",
"ctldl.windowsupdate.com",
]
GOOGLE_HOSTS = [
"clients3.google.com",
"mtalk.google.com",
"alt7-mtalk.google.com",
"alt6-mtalk.google.com",
"connectivitycheck.android.com",
"connectivitycheck.gstatic.com",
"developers.google.cn",
]
LINUX_HOSTS = ["connectivity-check.ubuntu.com", "nmcheck.gnome.org"]
FIREFOX_HOSTS = ["detectportal.firefox.com"]
def has_internet():
try:
with open("/tmp/has_internet", "r") as f:
return f.read().strip() == "yes"
except Exception:
return False
def is_active(ip_addr):
conntrack_ps = subprocess.run(
["/usr/sbin/conntrack", "-L"], capture_output=True, text=True
)
for line in conntrack_ps.stdout.splitlines():
if "ESTABLISHED" in line and ip_addr in line:
return True
return False
def fw_allow_host(ip_addr):
""" add ip_addr to iptable's CAPTIVE_PASSLIST to skip portal """
passlist_ps = subprocess.run(
["/usr/bin/sudo", "/usr/sbin/iptables", "-t", "nat", "-nL", "CAPTIVE_PASSLIST"],
capture_output=True,
text=True,
)
passlist = [
re.split(r"\s+", line)[3]
for line in passlist_ps.stdout.splitlines()
if "--" in line
]
if ip_addr in passlist:
return
subprocess.run(
[
"/usr/bin/sudo",
"/usr/sbin/iptables",
"-t",
"nat",
"-I",
"CAPTIVE_PASSLIST",
"1",
"-s",
str(ip_addr),
"-j",
"ACCEPT",
]
)
def is_google_request(request):
return request.path == "/gen_204" or request.path == "/generate_204"
def is_apple_request(request):
return request.host in APPLE_HOSTS
def is_microsoft_request(request):
return request.host in MICROSOFT_HOSTS
def is_microsoft_ncsi_request(request):
return request.host == "www.msftncsi.com" and request.path == "/ncsi.txt"
def is_linux_request(request):
return request.host in LINUX_HOSTS
def is_nmcheck_request(request):
return (
request.host == "nmcheck.gnome.org"
and request.path == "/check_network_status.txt"