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
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ Let's assume that the nethsecurity-controller instance is named `nethsecurity-co
Launch `configure-module`, by setting the following parameters:
- `host`: a fully qualified domain name for the controller
- `lets_encrypt`: enable or disable Let's Encrypt certificate
- `ovpn_network`: OpenVPN network
- `ovpn_netmask`: OpenVPN netmask
- `ovpn_network`: OpenVPN network, it must be the first address of the network
- `ovpn_netmask`: OpenVPN netmask, from `255.255.240.0` (/20) to `255.255.255.0` (/24)
- `ovpn_cn`: OpenVPN Certificate CN
- `api_user`: controller admin user
- `api_password`: controller admin password, change it after first login
Expand All @@ -49,7 +49,7 @@ Launch `configure-module`, by setting the following parameters:

Example:

api-cli run module/nethsecurity-controller1/configure-module --data '{"host": "mycontroller.nethsecurity.org", "lets_encrypt": false, "ovpn_network": "172.19.64.0", "ovpn_netmask": "255.255.255.0", "ovpn_cn": "nethsec", "api_user": "admin", "api_password": "password", "loki_retention": 180, "prometheus_retention": 15, "maxmind_license": "xxx"}'
api-cli run module/nethsecurity-controller1/configure-module --data '{"host": "mycontroller.nethsecurity.org", "lets_encrypt": false, "ovpn_network": "172.19.64.0", "ovpn_netmask": "255.255.240.0", "ovpn_cn": "nethsec", "api_user": "admin", "api_password": "password", "loki_retention": 180, "prometheus_retention": 15, "maxmind_license": "xxx"}'

The above command will:
- start and configure the nethsecurity-controller instance
Expand All @@ -65,6 +65,22 @@ The above command will:

Once the controller is configured, you access the controller URL, eg. `mycontroller.nethsecurity.org`, and manage NethSecurity units.

New controllers get a random `/20` VPN network, controllers installed before got a `/24`.
`configure-module` can't change VPN network and netmask after the first configuration, use `set-vpn-network` instead.

### Change the VPN network

Run `set-vpn-network`, e.g. to widen a `/24` to a `/20`:

api-cli run module/nethsecurity-controller1/set-vpn-network --data '{"ovpn_network": "172.19.64.0", "ovpn_netmask": "255.255.240.0"}'

The network address must be aligned to the netmask: `172.19.64.0` is valid for a `/20`, `172.19.65.0` is not.
Only expand the network: keep the same network address and widen the netmask, units keep their VPN IP.
Shrinking the network makes units with an IP outside it lose their connection entirely, changing the network address does it for all units.
The action rejects both, as well as netmasks outside `/20`-`/24`.
Add `"force": true` to skip these checks and allow netmasks wider than `/20`, `/24` is still the limit: units that lost the connection **must be removed and added again**.
The action stops the controller, creates the tun and the firewall rules again and starts the controller. Units reconnect on their own.

## Module overview

The module is composed by the following systemd units:
Expand Down
1 change: 1 addition & 0 deletions controller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ The following environment variables can be used to configure the containers:
- `OVPN_TUN`: OpenVPN tun device name, default is `tunsec`
- `OVPN_TUN_MTU`: OpenVPN tun device MTU, default is `1500`
- `OVPN_MSSFIX`: OpenVPN mssfix value, default is `1450`
- `OVPN_MAX_CLIENTS`: OpenVPN max connected clients, default is `1024`
- `UI_PORT`: UI listening port, default is `3000`
- `UI_BIND_IP`: UI binding IP, default is `0.0.0.0`
- `API_PORT`: API server listening port, default is `5000`
Expand Down
2 changes: 2 additions & 0 deletions controller/api/utils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ func TestListIPsEdgeCases(t *testing.T) {
{"32", "192.168.1.1", "255.255.255.255", 1},
{"31", "192.168.1.0", "255.255.255.254", 0},
{"30", "192.168.1.0", "255.255.255.252", 2},
{"24", "172.20.16.0", "255.255.255.0", 254},
{"20", "172.20.16.0", "255.255.240.0", 4094},
}

for _, tc := range testCases {
Expand Down
3 changes: 3 additions & 0 deletions controller/vpn/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ ovpn_port=${OVPN_UDP_PORT:-1194}
tun=${OVPN_TUN:-tunsec}
tun_mtu=${OVPN_TUN_MTU:-1500}
mssfix=${OVPN_MSSFIX:-1450}
max_clients=${OVPN_MAX_CLIENTS:-1024}

if [ ! -f /etc/openvpn/pki/ca.crt ]; then
cd /etc/openvpn
Expand Down Expand Up @@ -50,6 +51,8 @@ client-config-dir /etc/openvpn/ccd
ifconfig-pool-persist host-to-net.pool 0

port $ovpn_port
max-clients $max_clients
explicit-exit-notify 1
script-security 3
float
multihome
Expand Down
38 changes: 32 additions & 6 deletions imageroot/actions/configure-module/20configure
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,44 @@ import sys
import agent
import os
import uuid
import ipaddress

request = json.load(sys.stdin)

(start,end) = os.environ["TCP_PORTS_RANGE"].split('-')
ports = [*range(int(start), int(end)+1)]

def validation_failed(field, value, error):
agent.set_status('validation-failed')
json.dump([{'field': field, 'parameter': field, 'value': value, 'error': error}], fp=sys.stdout)
sys.exit(2)

first_config = False
try:
with open('config.json', 'r') as tmp:
config = json.load(tmp)
except:
config = request
first_config = True

ovpn_network = request['ovpn_network']
ovpn_netmask = request['ovpn_netmask']

# The tun address is set only once by add_tun, so the network can't change after the first configuration
if config['ovpn_network'] != ovpn_network:
validation_failed('network', ovpn_network, 'network_change_not_supported')
if config['ovpn_netmask'] != ovpn_netmask:
validation_failed('netmask', ovpn_netmask, 'network_change_not_supported')

try:
vpn_network = ipaddress.IPv4Network(f'{ovpn_network}/{ovpn_netmask}')
except ipaddress.NetmaskValueError:
validation_failed('netmask', ovpn_netmask, 'invalid_netmask')
except ValueError:
validation_failed('network', ovpn_network, 'invalid_network')
# set-vpn-network with force can store any netmask
if first_config and not 20 <= vpn_network.prefixlen <= 24:
validation_failed('netmask', ovpn_netmask, 'netmask_out_of_range')

for path in ['loki_path', 'prometheus_path', 'webssh_path']:
if not config.get(path):
Expand Down Expand Up @@ -120,6 +147,8 @@ with open('config.env', 'w') as env:
env.write(f'ADMIN_PASSWORD={request.get("api_password", config["api_password"])}\n')
env.write(f'OVPN_NETWORK={request["ovpn_network"]}\n')
env.write(f'OVPN_NETMASK={request["ovpn_netmask"]}\n')
# network, broadcast and server addresses excluded
env.write(f'OVPN_MAX_CLIENTS={vpn_network.num_addresses - 3}\n')
env.write(f'OVPN_CN={request["ovpn_cn"]}\n')
env.write(f'FQDN={request["host"]}\n')
env.write(f'ISSUER_2FA={request["host"]}\n')
Expand Down Expand Up @@ -170,11 +199,8 @@ with open('prometheus.env', 'w') as pfp:
network = agent.read_envfile('network.env')

# Listen also on VPN server address
listen_address = network.get('LISTEN_ADDRESS', '')
api_port = network.get('API_PORT', ports[1])
if server_address not in listen_address:
network['LISTEN_ADDRESS'] = f'{listen_address},{server_address}:{api_port}'
agent.write_envfile('network.env', network)
network['LISTEN_ADDRESS'] = f'127.0.0.1:{api_port},{server_address}:{api_port}'

# Setup allowed_ips
network['ALLOWED_IPS'] = ','.join(request.get('allowed_ips', []))
Expand All @@ -183,8 +209,8 @@ agent.write_envfile('network.env', network)

# Setup firewall
tun = network.get('OVPN_TUN')
bits = sum(bin(int(x)).count('1') for x in request["ovpn_netmask"].split('.'))
cidr = f'{request["ovpn_network"]}/{bits}'
bits = vpn_network.prefixlen
cidr = vpn_network.with_prefixlen
# Allow access only on Promtail and API ports, reject everything else
rules = [
f'rule family=ipv4 priority=-100 source address={server_address} destination address={cidr} accept',
Expand Down
4 changes: 2 additions & 2 deletions imageroot/actions/configure-module/70platform_info
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
import json
import agent
import os
import ipaddress

vpn_port = os.environ['TCP_PORTS_RANGE'].split('-')[0]
controller_version = os.environ.get('IMAGE_URL', '').split(':')[-1]

config = agent.read_envfile('config.env')
bits = sum(bin(int(x)).count('1') for x in config.get('OVPN_NETWORK', '').split('.'))
vpn_network = f"{config.get('OVPN_NETWORK', '')}/{bits}"
vpn_network = ipaddress.IPv4Network(f"{config['OVPN_NETWORK']}/{config['OVPN_NETMASK']}").with_prefixlen
metrics_retension_days = config.get('RETENTION_DAYS', '30')

loki = agent.read_envfile('loki.env')
Expand Down
8 changes: 4 additions & 4 deletions imageroot/actions/configure-module/validate-input.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
"api_user": "admin",
"api_password": "admin",
"host": "controller.nethserver.org",
"ovpn_network": "127.2.10.0",
"ovpn_netmask": "255.255.0.0",
"ovpn_network": "172.20.16.0",
"ovpn_netmask": "255.255.240.0",
"ovpn_cn": "nethsec",
"loki_retention": 180,
"prometheus_retention": 15,
Expand Down Expand Up @@ -47,12 +47,12 @@
"ovpn_network": {
"type": "string",
"format": "ipv4",
"description": "VPN client network, like '127.2.1.0.0'"
"description": "VPN client network, like '172.20.16.0'. It can't change after the first configuration"
},
"ovpn_netmask": {
"type": "string",
"format": "ipv4",
"description": "VPN client netmask, like '255.255.0.0'"
"description": "VPN client netmask, from '255.255.240.0' (/20) to '255.255.255.0' (/24). It can't change after the first configuration"
},
"ovpn_cn": {
"type": "string",
Expand Down
8 changes: 4 additions & 4 deletions imageroot/actions/get-configuration/20read
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ if os.path.isfile('config.json'):
config["lets_encrypt"] = agent.get_route(os.environ['MODULE_ID']).get('lets_encrypt', False)
else:
# Prepare random values for first-configuration
# Pick a newtork inside 172.16.0.0/12 (range 172.16.0.0-172.31.255.255)
foctet=random.randrange(16,31)
soctet=random.randrange(1,254)
# Pick a /20 network inside 172.16.0.0/12 (range 172.16.0.0-172.31.255.255)
foctet=random.randrange(16,32)
soctet=16*random.randrange(0,16)

config = {'host': '', 'lets_encrypt': False, 'ovpn_network': f'172.{foctet}.{soctet}.0', 'ovpn_netmask': '255.255.255.0', 'ovpn_cn': 'nethsec', 'api_user': 'admin', 'api_password': '', 'loki_retention': 180, 'prometheus_retention': 15, 'vpn_port': '', 'allowed_ips': [], 'tun_mtu': 1500, 'mssfix': 1450}
config = {'host': '', 'lets_encrypt': False, 'ovpn_network': f'172.{foctet}.{soctet}.0', 'ovpn_netmask': '255.255.240.0', 'ovpn_cn': 'nethsec', 'api_user': 'admin', 'api_password': '', 'loki_retention': 180, 'prometheus_retention': 15, 'vpn_port': '', 'allowed_ips': [], 'tun_mtu': 1500, 'mssfix': 1450}

characters = list(string.ascii_letters + string.digits + "!@#%^+_")
random.shuffle(characters)
Expand Down
4 changes: 2 additions & 2 deletions imageroot/actions/get-configuration/validate-output.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
"api_password": "admin",
"host": "controller.nethserver.org",
"lets_encrypt": true,
"ovpn_network": "127.2.10.0",
"ovpn_netmask": "255.255.0.0",
"ovpn_network": "172.20.16.0",
"ovpn_netmask": "255.255.240.0",
"ovpn_cn": "nethsec",
"loki_retention": 180,
"maxmind_license": "1234567890",
Expand Down
61 changes: 61 additions & 0 deletions imageroot/actions/set-vpn-network/20set_network
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env python3

#
# Copyright (C) 2026 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-3.0-or-later
#

import os
import sys
import json
import ipaddress
import subprocess
import agent
import agent.tasks

request = json.load(sys.stdin)

def validation_failed(field, value, error):
agent.set_status('validation-failed')
json.dump([{'field': field, 'parameter': field, 'value': value, 'error': error}], fp=sys.stdout)
sys.exit(2)

ovpn_network = request['ovpn_network']
ovpn_netmask = request['ovpn_netmask']

try:
vpn_network = ipaddress.IPv4Network(f'{ovpn_network}/{ovpn_netmask}')
except ipaddress.NetmaskValueError:
validation_failed('ovpn_netmask', ovpn_netmask, 'invalid_netmask')
except ValueError:
validation_failed('ovpn_network', ovpn_network, 'invalid_network')
if vpn_network.prefixlen > 24:
validation_failed('ovpn_netmask', ovpn_netmask, 'netmask_out_of_range')

with open('config.json', 'r') as cf:
config = json.load(cf)

# Units keep their stored VPN address, it must stay inside the new network
if not request.get('force', False):
if vpn_network.prefixlen < 20:
validation_failed('ovpn_netmask', ovpn_netmask, 'netmask_out_of_range')
current_network = ipaddress.IPv4Network(f'{config["ovpn_network"]}/{config["ovpn_netmask"]}')
if ovpn_network != config['ovpn_network']:
validation_failed('ovpn_network', ovpn_network, 'network_change_not_supported')
if vpn_network.prefixlen > current_network.prefixlen:
validation_failed('ovpn_netmask', ovpn_netmask, 'netmask_shrink_not_supported')

subprocess.run(['systemctl', '--user', 'stop', 'controller.service'], check=True)

network = agent.read_envfile('network.env')
agent.assert_exp(agent.remove_tun(network.get('OVPN_TUN')))

config['ovpn_network'] = ovpn_network
config['ovpn_netmask'] = ovpn_netmask
with open('config.json', 'w') as cf:
json.dump(config, cf)

# Keep the current Let's Encrypt setting of the route
config.pop('lets_encrypt', None)
response = agent.tasks.run(agent_id=os.environ['AGENT_ID'], action='configure-module', data=config)
agent.assert_exp(response['exit_code'] == 0)
30 changes: 30 additions & 0 deletions imageroot/actions/set-vpn-network/validate-input.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Set the VPN network",
"$id": "http://nethserver.org/json-schema/task/input/nethsec-controller/set-vpn-network",
"description": "Change the VPN network of nethsec-controller, recreating the tun and restarting the controller",
"examples": [
{
"ovpn_network": "172.20.16.0",
"ovpn_netmask": "255.255.240.0"
}
],
"type": "object",
"required": [ "ovpn_network", "ovpn_netmask" ],
"properties": {
"ovpn_network": {
"type": "string",
"format": "ipv4",
"description": "VPN client network, it must be the first address of the network, like '172.20.16.0'. It must be equal to the current one, unless force is set"
},
"ovpn_netmask": {
"type": "string",
"format": "ipv4",
"description": "VPN client netmask, from '255.255.240.0' (/20) to '255.255.255.0' (/24). It can only be widened, unless force is set"
},
"force": {
"type": "boolean",
"description": "Skip the network and netmask checks and allow netmasks wider than /20, /24 is still the limit, units outside the new network must be removed and added again"
}
}
}
4 changes: 2 additions & 2 deletions imageroot/update-module.d/40firewall
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import json
import sys
import agent
import os
import ipaddress

(start,end) = os.environ["TCP_PORTS_RANGE"].split('-')
ports = [*range(int(start), int(end)+1)]
Expand All @@ -26,8 +27,7 @@ server_address = config["ovpn_network"].removesuffix('.0') + '.1'
network = agent.read_envfile('network.env')

tun = network.get('OVPN_TUN')
bits = sum(bin(int(x)).count('1') for x in config["ovpn_netmask"].split('.'))
cidr = f'{config["ovpn_network"]}/{bits}'
cidr = ipaddress.IPv4Network(f'{config["ovpn_network"]}/{config["ovpn_netmask"]}').with_prefixlen
rules = [
f'rule family=ipv4 priority=-100 source address={server_address} destination address={cidr} accept',
f'rule family=ipv4 priority=-99 source address={cidr} destination address={server_address} port port={ports[4]} protocol=tcp accept',
Expand Down
2 changes: 1 addition & 1 deletion tests/nextsec-controller.robot
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Check if nethsecurity-controller is installed correctly
Set Global Variable ${module_id} ${output.module_id}

Check if nethsecurity-controller can be configured
${out} ${err} ${rc} = Execute Command api-cli run module/${module_id}/configure-module --data '{"host": "controller.dom.test", "lets_encrypt": false, "api_user": "admin", "api_password": "Nethesis,1234", "ovpn_network": "172.19.64.0", "ovpn_netmask": "255.255.255.0", "ovpn_cn": "nethsec", "loki_retention": 180, "prometheus_retention": 15}'
${out} ${err} ${rc} = Execute Command api-cli run module/${module_id}/configure-module --data '{"host": "controller.dom.test", "lets_encrypt": false, "api_user": "admin", "api_password": "Nethesis,1234", "ovpn_network": "172.19.64.0", "ovpn_netmask": "255.255.240.0", "ovpn_cn": "nethsec", "loki_retention": 180, "prometheus_retention": 15}'
... return_rc=True return_stdout=True return_stderr=True
Should Be Equal As Integers ${rc} 0

Expand Down
10 changes: 7 additions & 3 deletions ui/public/i18n/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@
"cn_tooltip": "The field will be read-only after the first configuration",
"network_tooltip": "Make sure this network does not overlap with the units' networks. The field will be read-only after the first configuration",
"netmask_tooltip": "Do not change this value unless you know what you are doing. The field will be read-only after the first configuration",
"netmask_helper": "The netmask determines the size of the VPN network. Use only class C networks",
"netmask_helper": "The netmask determines the size of the VPN network, from 255.255.240.0 (/20) to 255.255.255.0 (/24)",
"invalid_network": "Invalid network, it must be the first address of the network, like 172.20.16.0 for a /20",
"invalid_netmask": "Invalid netmask",
"netmask_out_of_range": "Netmask must be between 255.255.240.0 (/20) and 255.255.255.0 (/24)",
"network_change_not_supported": "VPN network and netmask can't be changed after the first configuration",
"password_placeholder": "Password can be modified from the controller webapp",
"password_information_title": "Default password",
"password_information_description": "The default administrator password is displayed only once, please store it in a safe place",
Expand Down Expand Up @@ -105,8 +109,8 @@
"prometheus_rention_min": "Metrics retention must be greater than 1 day",
"loki_retention_max": "Logs retention must be less than 365 days",
"loki_retention_min": "Logs retention must be greater than 1 day",
"invalid_network": "Invalid network, it must be a class C network like 192.168.200.0",
"invalid_netmask": "Invalid netmask, it must be a valid netmask like 255.255.255.0",
"invalid_network": "Invalid network, it must end with .0, like 172.20.16.0",
"invalid_netmask": "Invalid netmask, it must be between 255.255.240.0 (/20) and 255.255.255.0 (/24)",
"invalid_allowed_ips": "Invalid allowed IPs, each entry must be a and IP or CIDR network.",
"tun_mtu_min": "TUN MTU must be at least 576",
"mssfix_min": "MSS Fix must be at least 0"
Expand Down
Loading
Loading