diff --git a/README.md b/README.md
index 0d07ab4..2b3f037 100644
--- a/README.md
+++ b/README.md
@@ -18,16 +18,19 @@
- 🎉 **New Features**
- **Moved from TinyDB to SQLite**: SQLite provide a better performance and loading speed when getting peers! Also avoided crashing the database due to **race condition**.
+ - **Added Gunicorn WSGI Server**: This could provide more stable on handling HTTP request, and more flexibility in the future (such as HTTPS support). **BIG THANKS to @pgalonza :heart: **
- **Add Peers by Bulk: ** User can add peers by bulk, just simply set the amount and click add.
- **Delete Peers by Bulk**: User can delete peers by bulk, without deleting peers one by one.
- **Download Peers in Zip**: User can download all *downloadable* peers in a zip.
- **Added Pre-shared Key to peers:** Now each peer can add with a pre-shared key to enhance security. Previously added peers can add the pre-shared key through the peer setting button.
-
+ - **Redirect Back to Previous Page:** The dashboard will now redirect you back to your previous page if the current session got timed out and you need to sign in again.
+
- 🪚 **Bug Fixed**
- [IP Sorting range issues #99](https://github.com/donaldzou/WGDashboard/issues/99) [❤️ @barryboom]
- [INvalid character written to tunnel json file #108](https://github.com/donaldzou/WGDashboard/issues/108) [❤️ @ ikidd]
- [Add IPv6 #91](https://github.com/donaldzou/WGDashboard/pull/91) [❤️ @ pgalonza]
- [Added MTU and PersistentKeepalive to QR code and download files #112](https://github.com/donaldzou/WGDashboard/pull/112) [:heart: @reafian]
+ - **And many other bugs provided by our beloved users** :heart:
- **🧐 Other Changes**
- **Key generating moved to front-end**: No longer need to use the server's WireGuard to generate keys, thanks to the `wireguard.js` from the [official repository](https://git.zx2c4.com/wireguard-tools/tree/contrib/keygen-html/wireguard.js)!
- **Peer transfer calculation**: each peer will now show all transfer amount (previously was only showing transfer amount from the last configuration start-up).
@@ -35,12 +38,15 @@
- **`wgd.sh` finally can update itself**: So now user could update the whole dashboard from `wgd.sh`, with the `update` command.
- **Minified JS and CSS files**: Although only a small changes on the file size, but I think is still a good practice to save a bit of bandwidth ;)
-
*And many other small changes for performance and bug fixes! :laughing:*
+> If you have any other brilliant ideas for this project, please shout it in here [#129](https://github.com/donaldzou/WGDashboard/issues/129) :heart:
+
+
## Table of Content
+
- [💡 Features](#-features)
- [📝 Requirement](#-requirement)
- [🛠 Install](#-install)
@@ -332,11 +338,18 @@ Endpoint = 0.0.0.0:51820
## ❓ How to update the dashboard?
+#### **Please note for user who is using `v2.3.1` or below**
+
+- For user who is using `v2.3.1` or below, please notice that all data that stored in the current database will **not** transfer to the new database. This is hard decision to move from TinyDB to SQLite. But SQLite does provide a thread-safe access and TinyDB doesn't. I couldn't find a safe way to transfer the data, so you need to do them manually... Sorry about that :pensive: . But I guess this would be a great start for future development :sunglasses:.
+
+
+
1. Change your directory to `wgdashboard`
+
```shell
cd wgdashboard
```
-
+
2. Update the dashboard
```shell
git pull https://github.com/donaldzou/WGDashboard.git v3.0 --force
diff --git a/src/dashboard.py b/src/dashboard.py
index bea2fe4..e8aca9d 100644
--- a/src/dashboard.py
+++ b/src/dashboard.py
@@ -17,6 +17,7 @@ import time
import re
import urllib.parse
import urllib.request
+import urllib.error
from datetime import datetime, timedelta
from operator import itemgetter
# PIP installed library
@@ -65,7 +66,6 @@ def get_dashboard_conf():
Get dashboard configuration
@return: configparser.ConfigParser
"""
-
config = configparser.ConfigParser(strict=False)
config.read(DASHBOARD_CONF)
return config
@@ -76,7 +76,6 @@ def set_dashboard_conf(config):
Write to configuration
@param config: Input configuration
"""
-
with open(DASHBOARD_CONF, "w", encoding='utf-8') as conf_object:
config.write(conf_object)
@@ -129,7 +128,6 @@ def get_conf_running_peer_number(config_name):
return running
-# TODO use modules for working with ini(configparser or wireguard)
# Read [Interface] section from configuration file
def read_conf_file_interface(config_name):
"""
@@ -154,10 +152,6 @@ def read_conf_file_interface(config_name):
return data
-# TODO use modules for working with ini(configparser or wireguard)
-# Tried to use configparser but it does not support sections with the same name
-
-
def read_conf_file(config_name):
"""
Get configurations from file of wireguard interface.
@@ -375,7 +369,6 @@ def get_all_peers_data(config_name):
get_allowed_ip(conf_peer_data, config_name)
-# Search for peers
def get_peers(config_name, search, sort_t):
"""
Get all peers.
@@ -428,7 +421,6 @@ def get_conf_pub_key(config_name):
return ""
-# Get configuration listen port
def get_conf_listen_port(config_name):
"""
Get listen port number.
@@ -452,8 +444,12 @@ def get_conf_listen_port(config_name):
return port
-# Get configuration total data
def get_conf_total_data(config_name):
+ """
+ Get configuration's total amount of data
+ @param config_name: Configuration name
+ @return: list
+ """
data = g.cur.execute("SELECT total_sent, total_receive, cumu_sent, cumu_receive FROM " + config_name)
upload_total = 0
download_total = 0
@@ -468,19 +464,21 @@ def get_conf_total_data(config_name):
return [total, upload_total, download_total]
-# Get configuration status
def get_conf_status(config_name):
+ """
+ Check if the configuration is running or not
+ @param config_name:
+ @return: Return a string indicate the running status
+ """
ifconfig = dict(ifcfg.interfaces().items())
-
return "running" if config_name in ifconfig.keys() else "stopped"
-# Get all configuration as a list
def get_conf_list():
"""Get all wireguard interfaces with status.
- :return: Return a list of dicts with interfaces and its statuses
- :rtype: list
+ @return: Return a list of dicts with interfaces and its statuses
+ @rtype: list
"""
conf = []
@@ -510,25 +508,13 @@ def get_conf_list():
return conf
-# Generate private key
-def gen_private_key():
- subprocess.run('wg genkey > private_key.txt && wg pubkey < private_key.txt > public_key.txt', shell=True)
- with open('private_key.txt', encoding='utf-8') as file_object:
- private_key = file_object.readline().strip()
- with open('public_key.txt', encoding='utf-8') as file_object:
- public_key = file_object.readline().strip()
- data = {"private_key": private_key, "public_key": public_key}
- return data
-
-
-# Generate public key
def gen_public_key(private_key):
"""Generate the public key.
- :param private_key: Pricate key
- :type private_key: str
- :return: Return dict with public key or error message
- :rtype: dict
+ @param private_key: Private key
+ @type private_key: str
+ @return: Return dict with public key or error message
+ @rtype: dict
"""
with open('private_key.txt', 'w', encoding='utf-8') as file_object:
@@ -570,8 +556,14 @@ def f_check_key_match(private_key, public_key, config_name):
return {'status': 'success'}
-# Check if there is repeated allowed IP
def check_repeat_allowed_ip(public_key, ip, config_name):
+ """
+ Check if there are repeated IPs
+ @param public_key: Public key of the peer
+ @param ip: IP of the peer
+ @param config_name: configuration name
+ @return: a JSON object
+ """
peer = g.cur.execute("SELECT COUNT(*) FROM " + config_name + " WHERE id = ?", (public_key,)).fetchone()
if peer[0] != 1:
return {'status': 'failed', 'msg': 'Peer does not exist'}
@@ -586,6 +578,11 @@ def check_repeat_allowed_ip(public_key, ip, config_name):
def f_available_ips(config_name):
+ """
+ Get a list of available IPs
+ @param config_name: Configuration Name
+ @return: list
+ """
config_interface = read_conf_file_interface(config_name)
if "Address" in config_interface:
existed = []
@@ -596,7 +593,6 @@ def f_available_ips(config_name):
existed.append(ipaddress.ip_address(add))
peers = g.cur.execute("SELECT allowed_ip FROM " + config_name).fetchall()
for i in peers:
- print(i[0])
add = i[0].split(",")
for k in add:
a, s = k.split("/")
@@ -620,6 +616,11 @@ Flask Functions
@app.teardown_request
def close_DB(exception):
+ """
+ Commit to the database for every request
+ @param exception: Exception
+ @return: None
+ """
if hasattr(g, 'db'):
g.db.commit()
g.db.close()
@@ -628,6 +629,10 @@ def close_DB(exception):
# Before request
@app.before_request
def auth_req():
+ """
+ Action before every request
+ @return: Redirect
+ """
if getattr(g, 'db', None) is None:
g.db = connect_db()
g.cur = g.db.cursor()
@@ -647,7 +652,7 @@ def auth_req():
else:
session['message'] = ""
conf.clear()
- return redirect(url_for("signin"))
+ return redirect("/signin?redirect=" + str(request.url))
else:
if request.endpoint in ['signin', 'signout', 'auth', 'settings', 'update_acct', 'update_pwd',
'update_app_ip_port', 'update_wg_conf_path']:
@@ -662,13 +667,11 @@ Sign In / Sign Out
"""
-# Sign In
@app.route('/signin', methods=['GET'])
def signin():
- """Sign in request.
-
- :return: TODO
- :rtype: TODO
+ """
+ Sign in request
+ @return: template
"""
message = ""
@@ -681,46 +684,43 @@ def signin():
# Sign Out
@app.route('/signout', methods=['GET'])
def signout():
- """Sign out request.
-
- :return: TODO
- :rtype: TODO
"""
-
+ Sign out request
+ @return: redirect back to sign in
+ """
if "username" in session:
session.pop("username")
- message = "Sign out successfully!"
- return render_template('signin.html', message=message)
+ return redirect(url_for('signin'))
-# Authentication
@app.route('/auth', methods=['POST'])
def auth():
- """Authentication request.
-
- :return: TODO
- :rtype: TODO
"""
-
+ Authentication request
+ @return: json object indicating verifying
+ """
+ data = request.get_json()
config = get_dashboard_conf()
- password = hashlib.sha256(request.form['password'].encode())
+ password = hashlib.sha256(data['password'].encode())
if password.hexdigest() == config["Account"]["password"] \
- and request.form['username'] == config["Account"]["username"]:
- session['username'] = request.form['username']
+ and data['username'] == config["Account"]["username"]:
+ session['username'] = data['username']
config.clear()
- return redirect(url_for("index"))
-
- session['message'] = "Username or Password is incorrect."
+ return jsonify({"status": True, "msg": ""})
config.clear()
- return redirect(url_for("signin"))
+ return jsonify({"status": False, "msg": "Username or Password is incorrect."})
+
+
+"""
+Index Page
+"""
@app.route('/', methods=['GET'])
def index():
- """Index Page Related.
-
- :return: TODO
- :rtype: TODO
+ """
+ Index page related
+ @return: Template
"""
msg = ""
if "switch_msg" in session:
@@ -733,10 +733,9 @@ def index():
# Setting Page
@app.route('/settings', methods=['GET'])
def settings():
- """Setting Page Related.
-
- :return: TODO
- :rtype: TODO
+ """
+ Settings page related
+ @return: Template
"""
message = ""
status = ""
@@ -757,13 +756,11 @@ def settings():
peer_remote_endpoint=config.get("Peers", "remote_endpoint"))
-# Update account username
@app.route('/update_acct', methods=['POST'])
def update_acct():
- """Change account user name.
-
- :return: TODO
- :rtype: TODO
+ """
+ Change dashboard username
+ @return: Redirect
"""
if len(request.form['username']) == 0:
@@ -786,13 +783,12 @@ def update_acct():
return redirect(url_for("settings"))
-# Update peer default settting
+# Update peer default setting
@app.route('/update_peer_default_config', methods=['POST'])
def update_peer_default_config():
- """Change default configurations for peers.
-
- :return: TODO
- :rtype: TODO
+ """
+ Update new peers default setting
+ @return: None
"""
config = get_dashboard_conf()
@@ -860,10 +856,9 @@ def update_peer_default_config():
# Update dashboard password
@app.route('/update_pwd', methods=['POST'])
def update_pwd():
- """Change account password.
-
- :return: TODO
- :rtype: TODO
+ """
+ Update dashboard password
+ @return: Redirect
"""
config = get_dashboard_conf()
@@ -894,10 +889,11 @@ def update_pwd():
return redirect(url_for("settings"))
-# Update dashboard IP and port
@app.route('/update_app_ip_port', methods=['POST'])
def update_app_ip_port():
- """Change port number of dashboard.
+ """
+ Update dashboard ip and port
+ @return: None
"""
config = get_dashboard_conf()
@@ -905,13 +901,15 @@ def update_app_ip_port():
config.set("Server", "app_port", request.form['app_port'])
set_dashboard_conf(config)
config.clear()
- os.system('bash wgd.sh restart')
+ os.system('./wgd.sh restart')
# Update WireGuard configuration file path
@app.route('/update_wg_conf_path', methods=['POST'])
def update_wg_conf_path():
- """Change path to dashboard configuration.
+ """
+ Update configuration path
+ @return: None
"""
config = get_dashboard_conf()
@@ -920,13 +918,14 @@ def update_wg_conf_path():
config.clear()
session['message'] = "WireGuard Configuration Path Update Successfully!"
session['message_status'] = "success"
- os.system('bash wgd.sh restart')
+ os.system('./wgd.sh restart')
-# Update configuration sorting
@app.route('/update_dashboard_sort', methods=['POST'])
def update_dashbaord_sort():
- """Configuration Page Related
+ """
+ Update configuration sorting
+ @return: Boolean
"""
config = get_dashboard_conf()
@@ -944,10 +943,10 @@ def update_dashbaord_sort():
# Update configuration refresh interval
@app.route('/update_dashboard_refresh_interval', methods=['POST'])
def update_dashboard_refresh_interval():
- """Change the refresh time.
-
- :return: Return text with result
- :rtype: str
+ """
+ Change the refresh time.
+ @return: Return text with result
+ @rtype: str
"""
preset_interval = ["5000", "10000", "30000", "60000"]
@@ -964,12 +963,11 @@ def update_dashboard_refresh_interval():
# Configuration Page
@app.route('/configuration/', methods=['GET'])
def configuration(config_name):
- """Show wireguard interface view.
-
- :param config_name: Name of WG interface
- :type config_name: str
- :return: TODO
- :rtype: TODO
+ """
+ Show wireguard interface view.
+ @param config_name: Name of WG interface
+ @type config_name: str
+ @return: Template
"""
config = get_dashboard_conf()
@@ -1004,12 +1002,11 @@ def configuration(config_name):
# Get configuration details
@app.route('/get_config/', methods=['GET'])
def get_conf(config_name):
- """Get configuration setting of wireguard interface.
-
- :param config_name: Name of WG interface
- :type config_name: str
- :return: TODO
- :rtype: TODO
+ """
+ Get configuration setting of wireguard interface.
+ @param config_name: Name of WG interface
+ @type config_name: str
+ @return: TODO
"""
config_interface = read_conf_file_interface(config_name)
@@ -1050,12 +1047,11 @@ def get_conf(config_name):
# Turn on / off a configuration
@app.route('/switch/', methods=['GET'])
def switch(config_name):
- """On/off the wireguard interface.
-
- :param config_name: Name of WG interface
- :type config_name: str
- :return: TODO
- :rtype: TODO
+ """
+ On/off the wireguard interface.
+ @param config_name: Name of WG interface
+ @type config_name: str
+ @return: redirects
"""
status = get_conf_status(config_name)
@@ -1078,6 +1074,11 @@ def switch(config_name):
@app.route('/add_peer_bulk/', methods=['POST'])
def add_peer_bulk(config_name):
+ """
+ Add peers by bulk
+ @param config_name: Configuration Name
+ @return: String
+ """
data = request.get_json()
keys = data['keys']
endpoint_allowed_ip = data['endpoint_allowed_ip']
@@ -1138,9 +1139,13 @@ def add_peer_bulk(config_name):
return exc.output.strip()
-# Add peer
@app.route('/add_peer/', methods=['POST'])
def add_peer(config_name):
+ """
+ Add Peers
+ @param config_name: configuration name
+ @return: string
+ """
data = request.get_json()
public_key = data['public_key']
allowed_ips = data['allowed_ips']
@@ -1172,7 +1177,6 @@ def add_peer(config_name):
if enable_preshared_key:
now = str(datetime.now().strftime("%m%d%Y%H%M%S"))
f_name = now + "_tmp_psk.txt"
- print(f_name)
f = open(f_name, "w+")
f.write(preshared_key)
f.close()
@@ -1192,15 +1196,14 @@ def add_peer(config_name):
return exc.output.strip()
-# Remove peer
@app.route('/remove_peer/', methods=['POST'])
def remove_peer(config_name):
- """Remove peer.
-
- :param config_name: Name of WG interface
- :type config_name: str
- :return: Return result of action or recommendations
- :rtype: str
+ """
+ Remove peer.
+ @param config_name: Name of WG interface
+ @type config_name: str
+ @return: Return result of action or recommendations
+ @rtype: str
"""
if get_conf_status(config_name) == "stopped":
@@ -1231,15 +1234,14 @@ def remove_peer(config_name):
return "true"
-# Save peer settings
@app.route('/save_peer_setting/', methods=['POST'])
def save_peer_setting(config_name):
- """Save peer configuration.
+ """
+ Save peer configuration.
- :param config_name: Name of WG interface
- :type config_name: str
- :return: Return status of action and text with recommendations
- :rtype: TODO
+ @param config_name: Name of WG interface
+ @type config_name: str
+ @return: Return status of action and text with recommendations
"""
data = request.get_json()
@@ -1296,12 +1298,12 @@ def save_peer_setting(config_name):
# Get peer settings
@app.route('/get_peer_data/', methods=['POST'])
def get_peer_name(config_name):
- """Get peer settings.
+ """
+ Get peer settings.
- :param config_name: Name of WG interface
- :type config_name: str
- :return: Return settings of peer
- :rtype: TODO
+ @param config_name: Name of WG interface
+ @type config_name: str
+ @return: Return settings of peer
"""
data = request.get_json()
@@ -1321,41 +1323,14 @@ def available_ips(config_name):
return jsonify(f_available_ips(config_name))
-# Generate a private key
-@app.route('/generate_peer', methods=['GET'])
-def generate_peer():
- """Generate the private key for peer.
-
- :return: Return dict with private, public and preshared keys
- :rtype: TODO
- """
-
- return jsonify(gen_private_key())
-
-
-# Generate a public key from a private key
-@app.route('/generate_public_key', methods=['POST'])
-def generate_public_key():
- """Generate the public key.
-
- :return: Return dict with public key or error message
- :rtype: TODO
- """
-
- data = request.get_json()
- private_key = data['private_key']
- return jsonify(gen_public_key(private_key))
-
-
# Check if both key match
@app.route('/check_key_match/', methods=['POST'])
def check_key_match(config_name):
- """TODO
-
- :param config_name: Name of WG interface
- :type config_name: str
- :return: Return dictionary with status
- :rtype: TODO
+ """
+ Check key matches
+ @param config_name: Name of WG interface
+ @type config_name: str
+ @return: Return dictionary with status
"""
data = request.get_json()
@@ -1366,6 +1341,11 @@ def check_key_match(config_name):
@app.route("/qrcode/", methods=['GET'])
def generate_qrcode(config_name):
+ """
+ Generate QRCode
+ @param config_name: Configuration Name
+ @return: Template containing QRcode img
+ """
peer_id = request.args.get('id')
get_peer = g.cur.execute(
"SELECT private_key, allowed_ip, DNS, mtu, endpoint_allowed_ip, keepalive, preshared_key FROM "
@@ -1396,9 +1376,13 @@ def generate_qrcode(config_name):
return redirect("/configuration/" + config_name)
-# Download all configuration file
@app.route('/download_all/', methods=['GET'])
def download_all(config_name):
+ """
+ Download all configuration
+ @param config_name: Configuration Name
+ @return: JSON Object
+ """
get_peer = g.cur.execute(
"SELECT private_key, allowed_ip, DNS, mtu, endpoint_allowed_ip, keepalive, preshared_key, name FROM "
+ config_name + " WHERE private_key != ''").fetchall()
@@ -1445,6 +1429,11 @@ def download_all(config_name):
# Download configuration file
@app.route('/download/', methods=['GET'])
def download(config_name):
+ """
+ Download one configuration
+ @param config_name: Configuration name
+ @return: JSON object
+ """
peer_id = request.args.get('id')
get_peer = g.cur.execute(
"SELECT private_key, allowed_ip, DNS, mtu, endpoint_allowed_ip, keepalive, preshared_key, name FROM "
@@ -1491,15 +1480,15 @@ def download(config_name):
return jsonify({"status": False, "filename": "", "content": ""})
-# Switch peer display mode
@app.route('/switch_display_mode/', methods=['GET'])
def switch_display_mode(mode):
- """Change display view style.
+ """
+ Change display view style.
- :param mode: Mode name
- :type mode: str
- :return: Return text with result
- :rtype: str
+ @param mode: Mode name
+ @type mode: str
+ @return: Return text with result
+ @rtype: str
"""
if mode in ['list', 'grid']:
@@ -1519,10 +1508,11 @@ Dashboard Tools Related
# Get all IP for ping
@app.route('/get_ping_ip', methods=['POST'])
def get_ping_ip():
- """Get ips for network testing.
+ # TODO: convert return to json object
- :return: TODO
- :rtype: TODO
+ """
+ Get ips for network testing.
+ @return: HTML containing a list of IPs
"""
config = request.form['config']
@@ -1545,10 +1535,10 @@ def get_ping_ip():
# Ping IP
@app.route('/ping_ip', methods=['POST'])
def ping_ip():
- """Execute ping command.
-
- :return: Return text with result
- :rtype: str
+ """
+ Execute ping command.
+ @return: Return text with result
+ @rtype: str
"""
try:
@@ -1573,10 +1563,11 @@ def ping_ip():
# Traceroute IP
@app.route('/traceroute_ip', methods=['POST'])
def traceroute_ip():
- """Execute ping traceroute command.
+ """
+ Execute ping traceroute command.
- :return: Return text with result
- :rtype: str
+ @return: Return text with result
+ @rtype: str
"""
try:
@@ -1600,21 +1591,22 @@ Dashboard Initialization
def init_dashboard():
- """Create dashboard default configuration.
+ """
+ Create dashboard default configuration.
"""
# Set Default INI File
if not os.path.isfile(DASHBOARD_CONF):
open(DASHBOARD_CONF, "w+").close()
config = get_dashboard_conf()
- # Defualt dashboard account setting
+ # Default dashboard account setting
if "Account" not in config:
config['Account'] = {}
if "username" not in config['Account']:
config['Account']['username'] = 'admin'
if "password" not in config['Account']:
config['Account']['password'] = '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918'
- # Defualt dashboard server setting
+ # Default dashboard server setting
if "Server" not in config:
config['Server'] = {}
if 'wg_conf_path' not in config['Server']:
@@ -1651,24 +1643,28 @@ def init_dashboard():
def check_update():
- """Dashboard check update
+ """
+ Dashboard check update
- :return: Retunt text with result
- :rtype: str
+ @return: Retunt text with result
+ @rtype: str
"""
config = get_dashboard_conf()
- data = urllib.request.urlopen("https://api.github.com/repos/donaldzou/WGDashboard/releases").read()
- output = json.loads(data)
- release = []
- for i in output:
- if not i["prerelease"]:
- release.append(i)
- if config.get("Server", "version") == release[0]["tag_name"]:
- result = "false"
- else:
- result = "true"
+ try:
+ data = urllib.request.urlopen("https://api.github.com/repos/donaldzou/WGDashboard/releases").read()
+ output = json.loads(data)
+ release = []
+ for i in output:
+ if not i["prerelease"]:
+ release.append(i)
+ if config.get("Server", "version") == release[0]["tag_name"]:
+ result = "false"
+ else:
+ result = "true"
- return result
+ return result
+ except urllib.error.HTTPError:
+ return "false"
"""
diff --git a/src/requirements.txt b/src/requirements.txt
index 6bf8c79..5d3b347 100644
--- a/src/requirements.txt
+++ b/src/requirements.txt
@@ -1,5 +1,4 @@
Flask
-tinydb==4.5.2
ifcfg
icmplib
flask-qrcode
diff --git a/src/templates/signin.html b/src/templates/signin.html
index 4d6835b..da86297 100644
--- a/src/templates/signin.html
+++ b/src/templates/signin.html
@@ -16,11 +16,10 @@