Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions packages/ns-api/files/ns.controller
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/python3

#
# Copyright (C) 2024 Nethesis S.r.l.
# Copyright (C) 2026 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-2.0-only
#

Expand Down Expand Up @@ -261,9 +261,14 @@ def dump_dpi_stats():
return {"data": ret}

def dump_openvpn_connections():
# Parse /var/run/openvpn/connections.db for the last 20 minutes
# Parse openvpn connections.db for the last 20 minutes.
# If /mnt/data/openvpn exists, read from there, otherwise use /var/openvpn.
ret = []
for db_file in glob.glob('/var/openvpn/*/connections.db'):
if os.path.isdir('/mnt/data/openvpn'):
pattern = '/mnt/data/openvpn/*/connections.db'
else:
pattern = '/var/openvpn/*/connections.db'
for db_file in glob.glob(pattern):
instance = db_file.split('/')[-2]
conn = sqlite3.connect(db_file)
cursor = conn.cursor()
Expand Down
170 changes: 157 additions & 13 deletions packages/ns-api/files/ns.ovpnrw
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ import ipaddress
import subprocess
from euci import EUci
from nethsec import utils, ovpn, firewall, users, objects
import datetime as _datetime
from datetime import datetime, timezone
import tarfile
import io
import sqlite3
import time
import time as _time
import datetime
import csv
from zoneinfo import ZoneInfo
Expand Down Expand Up @@ -230,6 +231,32 @@ def get_user_extra_config(u, user_id):
except:
return {}

def archive_connections_db(instance):
timestamp = _datetime.datetime.now().strftime('%Y%m%d%H%M%S')
var_db = f'/var/openvpn/{instance}/connections.db'
storage_db = f'/mnt/data/openvpn/{instance}/connections.db'
# archive /var DB
if os.path.exists(var_db):
archive_path = var_db.replace('connections.db', f'connections-{timestamp}.db')
try:
shutil.move(var_db, archive_path)
except:
pass
# archive /mnt/data DB if it exists
if os.path.exists(storage_db):
archive_path = storage_db.replace('connections.db', f'connections-{timestamp}.db')
try:
shutil.move(storage_db, archive_path)
except:
pass

def get_connections_db_path(ovpninstance):
storage_db = f'/mnt/data/openvpn/{ovpninstance}/connections.db'
var_db = f'/var/openvpn/{ovpninstance}/connections.db'
if os.path.isdir('/mnt/data') and os.path.exists(storage_db):
return storage_db
return var_db

## APIs

def list_bridges():
Expand Down Expand Up @@ -356,6 +383,9 @@ def remove_instance(instance):
remove_tap_from_bridge(u, old_bridge, old_dev)

u.delete("openvpn", instance)

# archive connection history databases before removing the instance directory
archive_connections_db(instance)
shutil.rmtree(f"/etc/openvpn/{instance}", ignore_errors=True)
try:
# workaround: manually delete config since it's not removed from init script
Expand Down Expand Up @@ -665,7 +695,6 @@ def disconnect_user(ovpninstance, username):
return utils.generic_error("user_disconnect_failed")
return {"result": "success"}


def disable_user(ovpninstance, username):
u = EUci()
try:
Expand Down Expand Up @@ -966,7 +995,7 @@ def download_user_2fa(ovpninstance, username):
return utils.validation_error("username", "2fa_download_failed", username)

def connection_history_csv(ovpninstance, timezone):
database_path = f'/var/openvpn/{ovpninstance}/connections.db'
database_path = get_connections_db_path(ovpninstance)

try:
conn = sqlite3.connect(database_path)
Expand Down Expand Up @@ -1040,9 +1069,32 @@ def connection_history_csv(ovpninstance, timezone):
# Return the path of the CSV file
return {"csv_path": csv_file_path}


def connection_history(ovpninstance):
database_path = f'/var/openvpn/{ovpninstance}/connections.db'
def connection_history(ovpninstance, q='', time_range='', accounts=None, page=1, per_page=10, sort_by='startTime', desc=True):
database_path = get_connections_db_path(ovpninstance)

# map frontend field names to database column names
sort_field_mapping = {
'account': 'common_name',
'startTime': 'start_time',
'endTime': 'start_time + duration',
'duration': 'duration',
'virtualIpAddress': 'virtual_ip_addr',
'remoteIpAddress': 'remote_ip_addr'
}

# default to startTime if sort_by is invalid
if sort_by not in sort_field_mapping:
sort_by = 'startTime'

# determine if we need to sort in Python (for IPs) or SQL (for other fields)
sort_manually = sort_by in ['virtualIpAddress', 'remoteIpAddress']

if not sort_manually:
sort_column = sort_field_mapping[sort_by]
sort_order = 'DESC' if desc else 'ASC'
else:
sort_column = None
sort_order = None

try:
conn = sqlite3.connect(database_path)
Expand All @@ -1053,10 +1105,78 @@ def connection_history(ovpninstance):

try:
c = conn.cursor()
# Build SQL query based on whether there's a time constraint
rows = c.execute('''SELECT common_name, virtual_ip_addr, remote_ip_addr, start_time, duration, bytes_received, bytes_sent
FROM connections
ORDER BY start_time DESC''')
conditions = []
params = []

# time_range filter
now = int(_time.time())
if time_range == 'today':
start_of_day = int(_datetime.datetime.combine(_datetime.date.today(), _datetime.time.min).timestamp())
conditions.append('start_time >= ?')
params.append(start_of_day)
elif time_range == 'last_week':
conditions.append('start_time >= ?')
params.append(now - 7 * 86400)
elif time_range == 'last_month':
today = _datetime.date.today()
first_of_last_month = _datetime.date(today.year if today.month > 1 else today.year - 1, (today.month - 1) if today.month > 1 else 12, 1)
conditions.append('start_time >= ?')
params.append(int(_datetime.datetime.combine(first_of_last_month, _datetime.time.min).timestamp()))
elif time_range == 'last_3_months':
today = _datetime.date.today()
month = today.month - 3
year = today.year
if month <= 0:
month += 12
year -= 1
first_of_3_months_ago = _datetime.date(year, month, 1)
conditions.append('start_time >= ?')
params.append(int(_datetime.datetime.combine(first_of_3_months_ago, _datetime.time.min).timestamp()))
# 'all' or empty: no filter

# accounts filter
if accounts:
placeholders = ','.join('?' * len(accounts))
conditions.append(f'common_name IN ({placeholders})')
params.extend(accounts)

# free text search filter
if q:
conditions.append('(common_name LIKE ? OR virtual_ip_addr LIKE ? OR remote_ip_addr LIKE ?)')
like = f'%{q}%'
params.extend([like, like, like])

where = ('WHERE ' + ' AND '.join(conditions)) if conditions else ''

total = c.execute(f'SELECT COUNT(*) FROM connections {where}', params).fetchone()[0]
total_unfiltered = c.execute('SELECT COUNT(*) FROM connections').fetchone()[0]

if sort_manually:
# fetch all matching records for manual sorting
all_rows = c.execute(
f'''SELECT common_name, virtual_ip_addr, remote_ip_addr, start_time, duration, bytes_received, bytes_sent
FROM connections {where}''',
params
).fetchall()
if sort_by == 'virtualIpAddress':
all_rows = sorted(all_rows, key=lambda r: ipaddress.ip_address(r[1] or '0.0.0.0'), reverse=desc)
elif sort_by == 'remoteIpAddress':
all_rows = sorted(all_rows, key=lambda r: ipaddress.ip_address(r[2] or '0.0.0.0'), reverse=desc)

# apply pagination
offset = (page - 1) * per_page
rows = all_rows[offset:offset + per_page]
else:
# use SQL ORDER BY for non-IP fields
offset = (page - 1) * per_page
rows = c.execute(
f'''SELECT common_name, virtual_ip_addr, remote_ip_addr, start_time, duration, bytes_received, bytes_sent
FROM connections {where}
ORDER BY {sort_column} {sort_order}
LIMIT ? OFFSET ?''',
params + [per_page, offset]
)

for row in rows:
common_name, virtual_ip_addr, remote_ip_addr, start_time, duration, bytes_received, bytes_sent = row
end_time = start_time + duration if duration else None
Expand All @@ -1071,13 +1191,28 @@ def connection_history(ovpninstance):
'bytesSent': bytes_sent
})

# collect unique accounts from the full unfiltered result for the filters field
all_accounts = [r[0] for r in c.execute('SELECT DISTINCT common_name FROM connections ORDER BY common_name').fetchall()]

except:
conn.close()
return utils.generic_error("database_error")

conn.close()

return connections
last_page = max(1, (total + per_page - 1) // per_page)

return {
'connections': connections,
'current_page': page,
'last_page': last_page,
'per_page': per_page,
'total': total_unfiltered,
'results': total,
'filters': {
'accounts': all_accounts
}
}

def renew_server_certificate(ovpninstance):
try:
Expand Down Expand Up @@ -1142,7 +1277,7 @@ if cmd == 'list':
"download-user-2fa": {"instance": "roadwarrior1", "username": "myuser"},
"download_all_user_configurations": {"instance": "roadwarrior1"},
"connection-history-csv": {"instance": "roadwarrior1", "timezone": "Europe/Rome"},
"connection-history": {"instance": "roadwarrior1"},
"connection-history": {"instance": "roadwarrior1", "q": "", "time_range": "all", "accounts": [], "page": 1, "per_page": 10, "sort_by": "startTime", "desc": True},
"renew-server-certificate": {"instance": "roadwarrior1"},
"regenerate-all-certificates": {"instance": "roadwarrior1"}
}))
Expand Down Expand Up @@ -1200,7 +1335,16 @@ else:
elif action == "connection-history-csv":
ret = connection_history_csv(args['instance'], args['timezone'])
elif action == "connection-history":
ret = connection_history(args['instance'])
ret = connection_history(
args['instance'],
q=args.get('q', ''),
time_range=args.get('time_range', ''),
accounts=args.get('accounts', []),
page=int(args.get('page', 1)),
per_page=int(args.get('per_page', 10)),
sort_by=args.get('sort_by', 'startTime'),
desc=args.get('desc', True)
)
elif action == "renew-server-certificate":
ret = renew_server_certificate(args["instance"])
elif action == "regenerate-all-certificates":
Expand Down
19 changes: 13 additions & 6 deletions packages/ns-api/files/ns.report
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/python3

#
# Copyright (C) 2024 Nethesis S.r.l.
# Copyright (C) 2026 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-2.0-only
#

Expand Down Expand Up @@ -37,6 +37,13 @@ def get_cached_report(report_name, cache_timeout=900):
return json.load(f)
return None

def get_ovpnrw_db_path(instance):
storage_db = f'/mnt/data/openvpn/{instance}/connections.db'
var_db = f'/var/openvpn/{instance}/connections.db'
if os.path.isdir('/mnt/data/openvpn') and os.path.exists(storage_db):
return storage_db
return var_db

## API

def tsip_attack_report():
Expand Down Expand Up @@ -199,7 +206,7 @@ def mwan_report():
}

def ovpnrw_list_days(instance):
conn = sqlite3.connect(f'/var/openvpn/{instance}/connections.db')
conn = sqlite3.connect(get_ovpnrw_db_path(instance))
cursor = conn.cursor()
try:
cursor.execute("""
Expand All @@ -215,7 +222,7 @@ def ovpnrw_list_days(instance):
return {"days": [day[0] for day in days]}

def ovpnrw_clients_by_day(instance, day):
conn = sqlite3.connect(f'/var/openvpn/{instance}/connections.db')
conn = sqlite3.connect(get_ovpnrw_db_path(instance))
cursor = conn.cursor()
try:
cursor.execute("""
Expand Down Expand Up @@ -245,7 +252,7 @@ def ovpnrw_clients_by_day(instance, day):
return {"clients": client_info_list}

def ovpnrw_count_clients_by_hour(instance, day):
conn = sqlite3.connect(f'/var/openvpn/{instance}/connections.db')
conn = sqlite3.connect(get_ovpnrw_db_path(instance))
cursor = conn.cursor()
try:
cursor.execute("""
Expand All @@ -268,7 +275,7 @@ def ovpnrw_count_clients_by_hour(instance, day):
return {"hours": hours_count}

def ovpnrw_bytes_by_hour(instance, day):
conn = sqlite3.connect(f'/var/openvpn/{instance}/connections.db')
conn = sqlite3.connect(get_ovpnrw_db_path(instance))
cursor = conn.cursor()
try:
cursor.execute("""
Expand All @@ -293,7 +300,7 @@ def ovpnrw_bytes_by_hour(instance, day):


def ovpnrw_bytes_by_hour_and_user(instance, day, user):
conn = sqlite3.connect(f'/var/openvpn/{instance}/connections.db')
conn = sqlite3.connect(get_ovpnrw_db_path(instance))
cursor = conn.cursor()
try:
cursor.execute("""
Expand Down
5 changes: 4 additions & 1 deletion packages/ns-openvpn/Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#
# Copyright (C) 2022 Nethesis S.r.l.
# Copyright (C) 2026 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-2.0-only
#

Expand Down Expand Up @@ -39,6 +39,7 @@ define Package/ns-openvpn/install
$(INSTALL_DIR) $(1)/usr/libexec/ns-openvpn/connect-scripts
$(INSTALL_DIR) $(1)/usr/libexec/ns-openvpn/disconnect-scripts
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_DIR) $(1)/etc/hotplug.d/block
$(INSTALL_BIN) ./files/ns-openvpnrw-add $(1)/usr/sbin
$(INSTALL_BIN) ./files/ns-openvpnrw-init-pki $(1)/usr/sbin
$(INSTALL_BIN) ./files/ns-openvpnrw-extend-crl $(1)/usr/sbin
Expand All @@ -49,6 +50,8 @@ define Package/ns-openvpn/install
$(INSTALL_BIN) ./files/openvpn-connect $(1)/usr/libexec/ns-openvpn/
$(INSTALL_BIN) ./files/openvpn-disconnect $(1)/usr/libexec/ns-openvpn/
$(INSTALL_BIN) ./files/init-connections-db $(1)/usr/libexec/ns-openvpn/
$(INSTALL_BIN) ./files/openvpn-merge-connections-db $(1)/usr/libexec/ns-openvpn/
$(INSTALL_BIN) ./files/20-openvpn-merge-connections-db $(1)/etc/hotplug.d/block/
$(INSTALL_BIN) ./files/openvpn-local-auth $(1)/usr/libexec/ns-openvpn/
$(INSTALL_BIN) ./files/openvpn-remote-auth $(1)/usr/libexec/ns-openvpn/
$(INSTALL_BIN) ./files/openvpn-otp-auth $(1)/usr/libexec/ns-openvpn/
Expand Down
2 changes: 1 addition & 1 deletion packages/ns-openvpn/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ See the `delete-user` API inside the [ns-api](../ns-api/#nsovpnrw) page.

### Accounting

Every client connection is tracked inside a SQLite database saved inside `/var/openvpn/<instance>/connections.db`.
Every client connection is tracked inside a SQLite database saved inside `/var/openvpn/<instance>/connections.db` or inside `/mnt/data/openvpn/<instance>/connections.db` if storage is configured. This allows to maintain the connection history also after a system reboot.
The database is initialized as soon as the `instance` is up using the `init-connections-db` script.

As default, all logs are sent to `/var/log/messages`.
Expand Down
22 changes: 22 additions & 0 deletions packages/ns-openvpn/files/20-openvpn-merge-connections-db
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/bin/sh
#
# Copyright (C) 2026 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-2.0-only
#
# Triggered by hotplug when a block device is added.
# If /mnt/data is mounted, sync OpenVPN connection records from /var to storage.

[ "$ACTION" = "add" ] || exit 0
[ "$DEVTYPE" = "partition" ] || exit 0
[ -x /usr/libexec/ns-openvpn/openvpn-merge-connections-db ] || exit 0

# Wait for /mnt/data to be mounted (up to 10 seconds)
i=0
while [ $i -lt 10 ] && ! grep -q ' /mnt/data ' /proc/mounts; do
sleep 1
i=$((i + 1))
done

grep -q ' /mnt/data ' /proc/mounts || exit 0

/usr/libexec/ns-openvpn/openvpn-merge-connections-db
Loading
Loading