Skip to content
Merged
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
14 changes: 10 additions & 4 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,15 @@ def dump_dpi_stats():
return {"data": ret}

def dump_openvpn_connections():
# Parse /var/run/openvpn/connections.db for the last 20 minutes
ret = []
for db_file in glob.glob('/var/openvpn/*/connections.db'):
# Parse openvpn connections.db for the last 20 minutes.
# If storage is configured and mounted, read from there, otherwise use /var/openvpn.
ret = []
u = EUci()
base_path = u.get('fstab', 'ns_data', 'target', default='')
if not os.path.isdir(base_path):
base_path = '/var'
pattern = os.path.join(base_path, '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
141 changes: 126 additions & 15 deletions packages/ns-api/files/ns.ovpnrw
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,8 @@ from datetime import datetime, timezone
import tarfile
import io
import sqlite3
import time
import datetime
import csv
from zoneinfo import ZoneInfo
from datetime import datetime

## Utils

Expand Down Expand Up @@ -230,6 +227,24 @@ def get_user_extra_config(u, user_id):
except:
return {}

def archive_connections_db(instance):
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
db_path = get_connections_db_path(instance)
# archive the currently used database
if os.path.exists(db_path):
archive_path = db_path.replace('connections.db', f'connections-{timestamp}.db')
try:
subprocess.run(['mv', db_path, archive_path], check=True)
except:
pass

def get_connections_db_path(ovpninstance):
u = EUci()
base_path = u.get('fstab', 'ns_data', 'target', default='')
if not os.path.isdir(base_path):
base_path = '/var'
return os.path.join(base_path, 'openvpn', ovpninstance, 'connections.db')

## APIs

def list_bridges():
Expand Down Expand Up @@ -356,6 +371,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 +683,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 +983,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 +1057,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='', start_time=0, 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 +1093,57 @@ 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
if start_time > 0:
conditions.append('start_time >= ?')
params.append(start_time)

# 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 +1158,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 +1244,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": "", "start_time": 123456123, "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 +1302,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', ''),
start_time=args.get('start_time', 0),
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):
u = EUci()
base_path = u.get('fstab', 'ns_data', 'target', default='')
if not os.path.isdir(base_path):
base_path = '/var'
return os.path.join(base_path, 'openvpn', instance, 'connections.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
35 changes: 35 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,35 @@
#!/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

logger -t openvpn-merge-connections-db "Storage device added: $DEVNAME, checking for OpenVPN connections database merge"

# Check if a storage target is configured in UCI
storage_target=$(uci -q get fstab.ns_data.target)
if [ -z "$storage_target" ]; then
logger -t openvpn-merge-connections-db "No storage configured in fstab.ns_data, skipping merge"
exit 0
fi

# Wait for the configured storage target to be mounted (up to 10 seconds)
i=0
while [ $i -lt 10 ] && ! awk -v target="${storage_target}" '$2 == target {found=1} END {exit !found}' /proc/mounts; do
sleep 1
i=$((i + 1))
done
Comment thread
m-dilorenzi marked this conversation as resolved.

# If the storage target is still not mounted, log a warning and exit. Merge will not be performed
if ! awk -v target="${storage_target}" '$2 == target {found=1} END {exit !found}' /proc/mounts; then
logger -t openvpn-merge-connections-db "Storage target ${storage_target} not mounted after 30 seconds, skipping merge"
exit 0
fi

logger -t openvpn-merge-connections-db "Starting merge of OpenVPN connections database from /var to ${storage_target}"
/usr/libexec/ns-openvpn/openvpn-merge-connections-db
Loading
Loading