Verified Commit 9315b11f authored by Štěpán Henek's avatar Štěpán Henek 🌩

remote tab added

parent 141bdc55
......@@ -17,12 +17,12 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import typing
import copy
from datetime import datetime
import base64
import logging
import time
import uuid
from bottle import Bottle, request, template, response, jinja2_template
from urllib.parse import urlencode
......@@ -33,7 +33,7 @@ from foris.guide import Workflow
from foris.utils.translators import gettext_dummy as gettext, _
from foris.config_handlers import (
backups, dns, misc, notifications, wan, lan, updater, wifi, networks,
guest, profile
guest, profile, remote
)
from foris.utils import login_required, messages, is_safe_redirect
from foris.middleware.bottle_csrf import CSRFPlugin
......@@ -68,6 +68,14 @@ class ConfigPageMixin(object):
"""
raise bottle.HTTPError(404, "No AJAX actions specified for this page.")
def call_insecure(self, identifier):
"""Handels insecure request (no login required)
:param namespace: namespace of the storage (e.g. tokens)
:return: object that can be passed as HTTP response to Bottle
"""
raise bottle.HTTPError(404, "No storage specified for this page.")
def default_template(self, **kwargs):
if self.template_type == "jinja2":
page_template = "%s.html.j2" % self.template
......@@ -146,7 +154,7 @@ class ConfigPageMixin(object):
class NotificationsConfigPage(ConfigPageMixin):
slug = "notifications"
menu_order = 10
menu_order = 9
template = "config/notifications"
userfriendly_title = gettext("Notifications")
......@@ -200,7 +208,7 @@ class NotificationsConfigPage(ConfigPageMixin):
class PasswordConfigPage(ConfigPageMixin, misc.PasswordHandler):
slug = "password"
menu_order = 11
menu_order = 10
template = "config/password"
template_type = "jinja2"
......@@ -230,6 +238,184 @@ class PasswordConfigPage(ConfigPageMixin, misc.PasswordHandler):
return result
class RemoteConfigPage(ConfigPageMixin, remote.RemoteHandler):
slug = "remote"
menu_order = 11
TOKEN_LINK_EXPIRATION = 30
token_links = {}
template = "config/remote"
userfriendly_title = gettext("Remote Access")
template_type = "jinja2"
@classmethod
def token_cleanup(cls):
now = time.time()
cls.token_links = {k: v for k, v in cls.token_links.items() if now <= v["expiration"]}
def render(self, **kwargs):
data = current_state.backend.perform("remote", "get_status")
kwargs["status"] = data["status"]
kwargs["tokens"] = data["tokens"]
kwargs["backend_data"] = self.backend_data
kwargs["revoke_token_form"] = self.get_token_id_form()
kwargs["generate_token_form"] = self.get_generate_token_form()
kwargs["download_token_form"] = self.get_token_id_form()
return super().render(**kwargs)
def save(self, *args, **kwargs):
kwargs["no_messages"] = True
result = super().save(*args, **kwargs)
if self.form.callback_results["enabled"]:
if self.form.callback_results["result"]:
messages.success(_("Remote access was sucessfully enabled."))
else:
messages.error(
_(
"Failed to enable the remote access. You are probably using "
"a message bus which doesn't support the remote access or "
"the CA for remote access hasn't been generated yet."
)
)
else:
if self.form.callback_results["result"]:
messages.success(_("Remote access was sucessfully disabled."))
else:
messages.error(_("Failed to disable remote access."))
return result
def _check_post(self):
if bottle.request.method != 'POST':
messages.error(_("Wrong HTTP method."))
bottle.redirect(reverse("config_page", page_name="remote"))
def _ajax_list_tokens(self):
data = current_state.backend.perform("remote", "get_status")
return template(
"config/_remote_tokens.html.j2",
tokens=data["tokens"],
template_adapter=bottle.Jinja2Template,
)
def _ajax_revoke_token(self):
self._check_post()
form = self.get_token_id_form(bottle.request.POST.decode())
if not form.data["token_id"]:
raise bottle.HTTPError(404, "token_id not found")
bottle.response.set_header("Content-Type", "application/json")
return current_state.backend.perform(
"remote", "revoke", {"id": form.data["token_id"]})
def _ajax_generate_token(self):
self._check_post()
form = self.get_generate_token_form(bottle.request.POST.decode())
if not form.data["name"]:
raise bottle.HTTPError(404, "name not found")
bottle.response.set_header("Content-Type", "application/json")
return current_state.backend.perform(
"remote", "generate_token", {"name": form.data["name"]})
def _ajax_prepare_token(self):
self._check_post()
RemoteConfigPage.token_cleanup()
form = self.get_token_id_form(bottle.request.POST.decode())
token_id = form.data.get("token_id")
if not token_id:
raise bottle.HTTPError(404, "id not found")
name = form.data.get("name", token_id)
res = current_state.backend.perform("remote", "get_token", {"id": form.data["token_id"]})
if res["status"] != "valid":
raise bottle.HTTPError(404, "token not found")
bottle.response.set_header("Content-Type", "application/json")
new_uuid = uuid.uuid4()
RemoteConfigPage.token_links[str(new_uuid)] = {
"expiration": time.time() + RemoteConfigPage.TOKEN_LINK_EXPIRATION,
"name": name,
"token": base64.b64decode(res["token"]),
}
return {
"url": reverse("config_insecure", page_name="remote", identifier=str(new_uuid)),
"expires_in": RemoteConfigPage.TOKEN_LINK_EXPIRATION,
}
def call_ajax_action(self, action):
if action == "generate-token":
return self._ajax_generate_token()
elif action == "revoke-token":
return self._ajax_revoke_token()
elif action == "list-tokens":
return self._ajax_list_tokens()
elif action == "prepare-token":
return self._ajax_prepare_token()
raise ValueError("Unknown AJAX action.")
def _action_generate_ca(self):
self._check_post()
messages.info(_("Starting to generate CA for remote access."))
current_state.backend.perform("remote", "generate_ca")
# don't need to handle async_id (should influence all clients)
bottle.redirect(reverse("config_page", page_name="remote"))
def _action_delete_ca(self):
self._check_post()
data = current_state.backend.perform("remote", "delete_ca")
if data["result"]:
messages.success(_("CA for remote access was sucessfully deleted."))
else:
messages.error(_("Failed to delete CA for remote access."))
bottle.redirect(reverse("config_page", page_name="remote"))
def call_insecure(self, identifier):
RemoteConfigPage.token_cleanup()
try:
record = RemoteConfigPage.token_links[identifier]
except KeyError:
raise bottle.HTTPError(404, "token url doesn't exists")
bottle.response.set_header("Content-Type", "application/x-gzip")
bottle.response.set_header(
"Content-Disposition", 'attachment; filename="token-%s.tar.gz"' % record["name"]
)
bottle.response.set_header("Content-Length", len(record["token"]))
return record["token"]
def call_action(self, action):
if action == "generate-ca":
self._action_generate_ca()
elif action == "delete-ca":
self._action_delete_ca()
elif action == "download-token":
self._action_download_token()
raise ValueError("Unknown action.")
@classmethod
def is_visible(cls):
if current_state.backend.name != "mqtt":
return False
return ConfigPageMixin.is_visible_static(cls)
@classmethod
def is_enabled(cls):
if current_state.backend.name != "mqtt":
return False
return ConfigPageMixin.is_enabled_static(cls)
class ProfileConfigPage(ConfigPageMixin, profile.ProfileHandler):
slug = "profile"
menu_order = 12
......@@ -656,6 +842,7 @@ class AboutConfigPage(ConfigPageMixin):
config_pages = {
e.slug: e for e in [
NotificationsConfigPage,
RemoteConfigPage,
PasswordConfigPage,
ProfileConfigPage,
NetworksConfigPage,
......@@ -816,6 +1003,15 @@ def config_ajax(page_name):
raise bottle.HTTPError(404, "Unknown action.")
def config_insecure(page_name, identifier):
ConfigPage = get_config_page(page_name)
config_page = ConfigPage(request.GET.decode())
try:
return config_page.call_insecure(identifier)
except ValueError:
raise bottle.HTTPError(404, "Unknown Insecure link")
def init_app():
app = Bottle()
app.install(CSRFPlugin())
......@@ -826,6 +1022,8 @@ def init_app():
callback=config_action_post)
app.route("/<page_name:re:.+>/action/<action:re:.+>", name="config_action",
callback=config_action)
app.route("/<page_name:re:.+>/insecure/<identifier:re:[0-9a-zA-Z-]+>",
name="config_insecure", callback=config_insecure)
app.route("/<page_name:re:.+>/", method="POST",
callback=config_page_post)
app.route("/<page_name:re:.+>/", name="config_page",
......
# Foris - web administration interface for OpenWrt
# Copyright (C) 2019 CZ.NIC, z.s.p.o. <http://www.nic.cz>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from .base import BaseConfigHandler
from foris import fapi
from foris.form import Checkbox, Number, Textbox
from foris.state import current_state
from foris.utils.translators import gettext_dummy as gettext, _
from foris.validators import InRange, LenRange, RegExp
class RemoteHandler(BaseConfigHandler):
# Translate status obtained via get_status
CLIENT_STATUS_VALID = gettext("valid")
CLIENT_STATUS_REVOKED = gettext("revoked")
CLIENT_STATUS_EXPIRED = gettext("expired")
CLIENT_STATUS_GENERATING = gettext("generating")
CLIENT_STATUS_LOST = gettext("lost")
TRANSLATION_MAP = {
"valid": CLIENT_STATUS_VALID,
"revoked": CLIENT_STATUS_REVOKED,
"expired": CLIENT_STATUS_EXPIRED,
"generating": CLIENT_STATUS_GENERATING,
"lost": CLIENT_STATUS_LOST,
}
userfriendly_title = gettext("Remote Access")
def __init__(self, *args, **kwargs):
self.backend_data = current_state.backend.perform("remote", "get_settings")
super().__init__(*args, **kwargs)
def get_form(self):
data = {
"enabled": self.backend_data["enabled"],
"port": self.backend_data["port"],
"wan_access": self.backend_data["wan_access"],
}
if self.data:
# Update from post
data.update(self.data)
form = fapi.ForisForm("remote", data)
config_section = form.add_section(name="set_remote", title=_(self.userfriendly_title))
config_section.add_field(
Checkbox, name="enabled", label=_("Enable remote access"),
)
config_section.add_field(
Checkbox, name="wan_access", label=_("Accessible via WAN"),
hint=_(
"If this option is check the device in the WAN network will be able to connect "
"to the configuration interface. Otherwise only devices on LAN will be able to "
"access the configuration interface."
),
).requires("enabled", True)
config_section.add_field(
Number, name="port", label=_("Port"),
hint=_(
"A port which will be opened for the remote configuration "
"of this device."
),
validator=[InRange(1, 2 ** 16 - 1)],
default=11884,
).requires("enabled", True)
def form_callback(data):
msg = {"enabled": data['enabled']}
if msg["enabled"]:
msg["port"] = int(data["port"])
msg["wan_access"] = data['wan_access']
res = current_state.backend.perform("remote", "update_settings", msg)
res['enabled'] = msg['enabled']
return "save_result", res # store {"result": ...} to be used later...
form.add_callback(form_callback)
return form
def get_generate_token_form(self, data=None):
generate_token_form = fapi.ForisForm("generate_remote_token", data)
token_section = generate_token_form.add_section("generate_token", title=None)
token_section.add_field(
Textbox, name="name", label=_("Token name"), required=True,
hint=_("The display name for the token. It must be shorter than 64 characters "
"and must contain only alphanumeric characters, dots, dashes and "
"underscores."),
validators=[
RegExp(_("Token name is invalid."), r'[a-zA-Z0-9_.-]+'), LenRange(1, 63)]
)
return generate_token_form
def get_token_id_form(self, data=None):
token_id_form = fapi.ForisForm("token_id_form", data)
token_section = token_id_form.add_section("token_id_section", title=None)
token_section.add_field(
Textbox, name="token_id", label="", required=True,
validators=[
RegExp(_("Token id is invalid."), r'([a-zA-Z0-9][a-zA-Z0-9])+')]
)
token_section.add_field(
Textbox, name="name", label=_("Token name"), required=False,
validators=[
RegExp(_("Token name is invalid."), r'[a-zA-Z0-9_.-]+'), LenRange(1, 63)],
)
return token_id_form
......@@ -202,3 +202,13 @@
#ntp-error
display: none
.dynamic-link-wrapper td
text-align: center
.dynamic-link-wrapper div
display: inline-block
border: 1px solid black
border-radius: 3px
margin: 3px
padding: 10px
{% if tokens %}
<table id="remote-tokens">
<thead>
<tr>
<th>{% trans %}Token{% endtrans %}</th>
<th>{% trans %}Status{% endtrans %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for token in tokens %}
<tr id="serial-{{ token["id"] }}-name-{{ token["name"] }}">
<td>{{ token["name"] }}</td>
{% if token['status'] == 'revoked' %}
<td title="{{ token['status_msg'] }}"><i class="fas fa-times"></i></td>
{% elif token['status'] == 'generating' %}
<td title="{{ token['status_msg'] }}"><i class="fas fa-clock"></i></td>
{% elif token['status'] == 'valid' %}
<td title="{{ token['status_msg'] }}"><i class="fas fa-check"></i></td>
{% else %}
<td>{{ token['status'] }}</td>
{% endif %}
<td>
<form action="{{ url("config_ajax", page_name="remote") }}" method="post" class="token-buttons">
<input type="hidden" name="token_id" value="{{ token["id"] }}">
<input type="hidden" name="name" value="{{ token["name"] }}">
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
{% if token['status'] == 'valid' %}
<button name="action" value="prepare-token" type="submit"><i class="fas fa-box"></i> {% trans %}Prepare Token{% endtrans %}</button>
{% endif %}
{% if token['status'] != 'revoked' and token['status'] != 'generating' %}
<button name="action" value="revoke-token" type="submit"><i class="fas fa-times"></i> {% trans %}Revoke{% endtrans %}</button>
{% endif %}
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div id="remote-tokens"></div>
{% endif %}
{% extends 'config/base.html.j2' %}
{% block config_base %}
<div id="page-remote" class="config-page">
{% include '_messages.html.j2' %}
<p>
{% trans %}Here you can set up your router to be configured remotely. The remote configuration is done via secure encrypted connection and each client is required to have a token issued by this device.{% endtrans %}
</p>
{% if status == "missing" %}
<h3>{% trans %}No certification authority{% endtrans %}</h3>
<p>
{% trans %}Currently there is no certificate authority(CA) dedicated to remote access. A CA is required to generate access tokens to authenticate. To proceed you need to generate it first.{% endtrans %}
<form method='post' action="{{ url("config_action", page_name="remote", action="generate-ca") }}">
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
<button name="download-config" type="submit">{% trans %}Generate CA{% endtrans %}</button>
</form>
</p>
{% elif status == "generating" %}
<h3>{% trans %}Generating certificate authority{% endtrans %}</h3>
<p>
{% trans %}The CA necessary for the remote administration. The time required for generating CA may differ. Please try to reload this page later.{% endtrans %}
</p>
<center><img src="{{ static("img/loader.gif") }}" alt="{% trans %}Loading...{% endtrans %}"></center>
<br/>
<center><form><button name="reload-page" type="submit">{% trans %}Reload page{% endtrans %}</button></form></center>
{% elif status == "ready" %}
<h3>{% trans %}Connection parameters{% endtrans %}</h3>
<p>
{% trans %}You CA seems to be ready to be used. Now you can safely enable/disable the remote access and set some of the parameters.{% endtrans %}
</p>
<form method='post' action='{{ url("config_page", page_name="remote") }}' class="config-form" id="remote-config-form">
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
{% for field in form.active_fields %}
{% include '_field.html.j2' %}
{% endfor %}
<div class="row">
<button name="apply" type="submit">{% trans %}Apply configuration{% endtrans %}</button>
</div>
</form>
{% if backend_data['enabled'] %}
<h3>{% trans %}Tokens{% endtrans %}</h3>
<p>{% trans %}Here you can create and revoke the client capability to connect to the remote administration.{% endtrans %}</p>
<form action="{{ url("config_ajax", page_name="remote") }}" method="post" class="config-form" id="create-token-form">
<input type="hidden" name="action" value="generate-token">
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
{% for field in generate_token_form.active_fields %}
{% include '_field.html.j2' %}
{% endfor %}
<button type="submit" name="send">{% trans %}Create{% endtrans %}</button>
</form>
{% include 'config/_remote_tokens.html.j2' %}
{% endif %}
<h3>{% trans %}Delete CA{% endtrans %}</h3>
<p>
{% if form.data['enabled'] %}
{% trans %}You can't delete the CA while the remote access is enabled. To delete the CA you need to disable the remote access first.{% endtrans %}
{% else %}
{% trans %}You can delete the whole CA. Note that all the cerificates issued by this CA will be useless and if you wanted to use this plugin, you'd need to generate a new CA first.{% endtrans %}
<form action="{{ url("config_action", page_name="remote", action="delete-ca") }}" method="post" id="delete-ca-form">
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
<button type="submit" name="send" id="reset-ca-submit">{% trans %}Delete CA{% endtrans %}</button>
</form>
{% endif %}
</p>
{% endif %}
</div>
{% if is_xhr is not defined %}
<script src="{{ static("js/contrib/jquery-qrcode-0.14.0.min.js") }}"></script>
<script>
Foris.WS["remote"] = (msg) => {
switch(msg["action"]) {
case "generate_ca":
case "delete_ca":
window.location.reload();
break;
case "generate_token":
case "revoke":
Foris.updateTokens();
break;
}
};
Foris.updateTokens = () => {
$.get('{{ url("config_ajax", page_name="remote") }}', {action: "list-tokens"})
.done((response) => {
$("#remote-tokens").replaceWith(response);
Foris.overrideTokenButtons();
})
.fail(function(xhr) {
if (xhr.responseJSON && xhr.responseJSON.loggedOut && xhr.responseJSON.loginUrl) {
window.location.replace(xhr.responseJSON.loginUrl);
return;
}
});
};
Foris.overrideTokenButtons = () => {
$(".token-buttons").submit((e) => {
e.preventDefault();
let form = $(e.currentTarget);
let action = $(e.originalEvent.explicitOriginalTarget).val();
let form_data = {};
let serialized = form.serializeArray();
for (record in serialized) {
form_data[serialized[record].name] = serialized[record].value;
}
let token_id = form_data.token_id;
switch (action) {
case "revoke-token":
$.ajax({
type: "POST",
url: form.attr('action'),
data: `${form.serialize()}&action=${action}`,
success: (data) => {},
});
form.find("button").prop('disabled', true);
break;
case "prepare-token":
$.ajax({
type: "POST",
url: form.attr('action'),
data: `${form.serialize()}&action=${action}`,
success: (data) => {},
})
.done(async (response) => {
form.parent().parent().after(`<tr class="dynamic-link-wrapper" style="display:none;" id="dynamic-link-wrapper-${token_id}" ><td colspan=3>{% trans %}<a href="${response.url}"><div id="remote-qrcode-${token_id}"></div></a><br/>Expires in:{% endtrans %} <span id="remote-expires-${token_id}">${response.expires_in.toFixed(1)}</span>s</td></tr>`);
$(`#dynamic-link-wrapper-${token_id}`).show(200);
// generate qr code
let full_url = `${window.location.protocol}//${window.location.hostname}${window.location.port}${response.url}`;
$(`#remote-qrcode-${token_id}`).empty().qrcode({
size: 200,
text: full_url,
});
// run timeout
for (let i = response.expires_in; i >= 0; i = Math.round((i - 0.1) * 100) / 100) {
await Foris.TimeoutPromiss((left) => $(`#remote-expires-${token_id}`).text(left.toFixed(1)), 0.1, i);
}
$(`#dynamic-link-wrapper-${token_id}`).hide(200);
await $(`#dynamic-link-wrapper-${token_id}`).promise();
$(`#dynamic-link-wrapper-${token_id}`).remove();
form.find("button").prop('disabled', false);
})
.fail(() => form.find("button").prop('disabled', false));
form.find("button").prop('disabled', true);
break;
}
});
};
Foris.overrideCreateToken = () => {
$("#create-token-form").submit((e) => {
e.preventDefault();
let form = $(e.currentTarget);
$.ajax({
type: "POST",
url: form.attr('action'),
data: form.serialize(),
success: (data) => {},
});
let name_input = form.find("input:text");
name_input.val("");
name_input.removeClass("field-validation-pass");
Foris.initParsley();
});
};
$(document).ready(function() {
Foris.overrideCreateToken();
Foris.overrideTokenButtons();
});
</script>
{% endif %}
{% endblock %}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment